feat(web): shared SectionCardHeader with docs links in every settings section

- SectionCardHeader replaces 33 hand-copied card headers across settings, rules and admin pages
- DocsLink + lib/docsPaths: every tenant-facing section links its documentation page (new tab)
- docsPaths guard test encodes the GitBook URL rule (group slug, not folder name)
- delete dead MaintenanceSection duplicate

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-02 11:02:45 +02:00
parent ec3f0ce237
commit 78a043db08
37 changed files with 645 additions and 870 deletions
@@ -1,6 +1,7 @@
"use client";
import type { AdminConfiguration } from "@/types/adminConfig";
import { SectionCardHeader } from "@/components/SectionCardHeader";
interface AdminConfigSettingsSectionProps {
loadingConfig: boolean;
@@ -87,17 +88,12 @@ export function AdminConfigSettingsSection({
}: AdminConfigSettingsSectionProps) {
return (
<div className="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-gray-800 dark:to-gray-800 border-2 border-indigo-300 dark:border-indigo-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-indigo-200 dark:border-indigo-700 bg-gradient-to-r from-indigo-100 to-blue-100 dark:from-indigo-900/40 dark:to-blue-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-indigo-900 dark:text-indigo-100">Global Settings</h2>
<p className="text-sm text-indigo-600 dark:text-indigo-300 mt-1">Configure global settings for all tenants</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminIndigo"
iconPath="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
title="Global Settings"
subtitle="Configure global settings for all tenants"
/>
<div className="p-6">
{loadingConfig ? (
<div className="text-center py-8">
@@ -3,6 +3,7 @@
import { useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { SectionCardHeader } from "@/components/SectionCardHeader";
interface ConfigReseedSectionProps {
getAccessToken: () => Promise<string | null>;
@@ -148,17 +149,12 @@ export function ConfigReseedSection({
<div className="space-y-6">
{/* Fetch & Reseed from GitHub */}
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-gray-800 dark:to-gray-800 border-2 border-emerald-300 dark:border-emerald-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-emerald-200 dark:border-emerald-700 bg-gradient-to-r from-emerald-100 to-teal-100 dark:from-emerald-900/40 dark:to-teal-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<div>
<h2 className="text-xl font-semibold text-emerald-900 dark:text-emerald-100">Fetch &amp; Reseed from GitHub</h2>
<p className="text-sm text-emerald-600 dark:text-emerald-300 mt-1">Fetch the latest rules from the GitHub repository and reseed all rule types</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminEmerald"
iconPath="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
title="Fetch & Reseed from GitHub"
subtitle="Fetch the latest rules from the GitHub repository and reseed all rule types"
/>
<div className="p-6 space-y-4">
<p className="text-sm text-emerald-900 dark:text-gray-200">
This operation fetches all rule definitions from the GitHub repository and writes them to Azure Table Storage:
@@ -11,6 +11,7 @@ import { TenantConfiguration } from "./TenantManagementSection";
import { TenantSearchSelect } from "./TenantSearchSelect";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { firstBlockedSessionId, blockedSessionCount } from "./blockedDeviceHelpers";
import { SectionCardHeader } from "@/components/SectionCardHeader";
interface ResolvedDevice {
sessionId: string;
@@ -309,17 +310,12 @@ function DeviceBlockSectionInner({
return (
<div className="bg-gradient-to-br from-red-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border-2 border-red-300 dark:border-red-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-red-200 dark:border-red-700 bg-gradient-to-r from-red-100 to-orange-100 dark:from-red-900/40 dark:to-orange-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<div>
<h2 className="text-xl font-semibold text-red-900 dark:text-red-100">Device Block Management</h2>
<p className="text-sm text-red-600 dark:text-red-300 mt-1">Temporarily block a rogue device from sending data. The agent will stop uploading when it receives the block signal.</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminRed"
iconPath="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
title="Device Block Management"
subtitle="Temporarily block a rogue device from sending data. The agent will stop uploading when it receives the block signal."
/>
<div className="p-6 space-y-6">
{/* Block / Kill a device form */}
<div>
@@ -6,6 +6,7 @@ import { ValidationIndicator } from "@/components/ValidationIndicator";
import { BuiltInSectionsList } from "@/components/diagnostics/BuiltInSectionsList";
import { DiagnosticsPathRow } from "@/components/diagnostics/DiagnosticsPathRow";
import type { DiagnosticsBuiltInSection, DiagnosticsLogPath } from "@/types/diagnostics";
import { SectionCardHeader } from "@/components/SectionCardHeader";
// Re-export so existing consumers importing from this component keep working
export type { DiagnosticsLogPath };
@@ -52,19 +53,12 @@ export function DiagnosticsLogPathsSection({
return (
<div className="bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-gray-800 dark:to-gray-800 border-2 border-teal-300 dark:border-teal-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-teal-200 dark:border-teal-700 bg-gradient-to-r from-teal-100 to-cyan-100 dark:from-teal-900/40 dark:to-cyan-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-teal-600 dark:text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-teal-900 dark:text-teal-100">Diagnostics Log Paths</h2>
<p className="text-sm text-teal-600 dark:text-teal-300 mt-1">
What every diagnostics package collects: the built-in sections compiled into the agent, plus the global paths below sent to all tenants. Tenants may add their own paths in Settings.
</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminTeal"
iconPath="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
title="Diagnostics Log Paths"
subtitle="What every diagnostics package collects: the built-in sections compiled into the agent, plus the global paths below sent to all tenants. Tenants may add their own paths in Settings."
/>
<div className="p-6 space-y-4">
{/* Info box */}
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-700 rounded-lg p-3 flex items-start space-x-2">
@@ -1,456 +0,0 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
interface MaintenanceSectionProps {
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
export function MaintenanceSection({
getAccessToken,
setError,
setSuccessMessage,
}: MaintenanceSectionProps) {
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
// Latest selectable day (yesterday), fixed at mount — render must stay pure
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
const [reseedingRules, setReseedingRules] = useState(false);
const [reseedingGatherRules, setReseedingGatherRules] = useState(false);
const [reseedingImePatterns, setReseedingImePatterns] = useState(false);
const [fetchingFromGitHub, setFetchingFromGitHub] = useState(false);
const handleTriggerMaintenance = async () => {
try {
setTriggeringMaintenance(true);
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(api.maintenance.trigger(maintenanceDate || undefined), getAccessToken, {
method: "POST",
});
if (!response.ok) {
throw new Error(`Failed to trigger maintenance: ${response.statusText}`);
}
const dateInfo = maintenanceDate ? ` for ${maintenanceDate}` : '';
setSuccessMessage(`Maintenance job completed successfully${dateInfo}!`);
// Auto-hide success message after 5 seconds
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while triggering maintenance");
} else {
console.error("Error triggering maintenance:", err);
}
setError(err instanceof Error ? err.message : "Failed to trigger maintenance job");
} finally {
setTriggeringMaintenance(false);
}
};
const handleReseedAnalyzeRules = async () => {
try {
setReseedingRules(true);
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(api.rules.reseedFromGitHub("analyze"), getAccessToken, {
method: "POST",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to reseed analyze rules: ${response.statusText}`);
}
const result = await response.json();
setSuccessMessage(`Analyze rules reseeded from GitHub: ${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written`);
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while reseeding analyze rules");
} else {
console.error("Error reseeding analyze rules:", err);
}
setError(err instanceof Error ? err.message : "Failed to reseed analyze rules");
} finally {
setReseedingRules(false);
}
};
const handleReseedGatherRules = async () => {
try {
setReseedingGatherRules(true);
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(api.rules.reseedFromGitHub("gather"), getAccessToken, {
method: "POST",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to reseed gather rules: ${response.statusText}`);
}
const result = await response.json();
setSuccessMessage(`Gather rules reseeded from GitHub: ${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written`);
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while reseeding gather rules");
} else {
console.error("Error reseeding gather rules:", err);
}
setError(err instanceof Error ? err.message : "Failed to reseed gather rules");
} finally {
setReseedingGatherRules(false);
}
};
const handleReseedImePatterns = async () => {
try {
setReseedingImePatterns(true);
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(api.rules.reseedFromGitHub("ime"), getAccessToken, {
method: "POST",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to reseed IME log patterns: ${response.statusText}`);
}
const result = await response.json();
setSuccessMessage(`IME log patterns reseeded from GitHub: ${result.ime?.deleted ?? 0} deleted, ${result.ime?.written ?? 0} written`);
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while reseeding IME log patterns");
} else {
console.error("Error reseeding IME log patterns:", err);
}
setError(err instanceof Error ? err.message : "Failed to reseed IME log patterns");
} finally {
setReseedingImePatterns(false);
}
};
const handleFetchFromGitHub = async () => {
try {
setFetchingFromGitHub(true);
setError(null);
setSuccessMessage(null);
const response = await authenticatedFetch(api.rules.reseedFromGitHub("all"), getAccessToken, {
method: "POST",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Failed to reseed from GitHub: ${response.statusText}`);
}
const result = await response.json();
setSuccessMessage(
`GitHub Reseed complete: Gather (${result.gather?.deleted ?? 0} deleted, ${result.gather?.written ?? 0} written), ` +
`Analyze (${result.analyze?.deleted ?? 0} deleted, ${result.analyze?.written ?? 0} written), ` +
`IME (${result.ime?.deleted ?? 0} deleted, ${result.ime?.written ?? 0} written)`
);
setTimeout(() => setSuccessMessage(null), 8000);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Session expired while reseeding from GitHub");
} else {
console.error("Error reseeding from GitHub:", err);
}
setError(err instanceof Error ? err.message : "Failed to reseed from GitHub");
} finally {
setFetchingFromGitHub(false);
}
};
return (
<>
{/* Manual Maintenance Trigger */}
<div className="bg-gradient-to-br from-purple-50 to-violet-50 dark:from-gray-800 dark:to-gray-800 border-2 border-purple-300 dark:border-purple-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-purple-200 dark:border-purple-700 bg-gradient-to-r from-purple-100 to-violet-100 dark:from-purple-900/40 dark:to-violet-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-purple-900 dark:text-purple-100">Manual Maintenance Trigger</h2>
<p className="text-sm text-purple-600 dark:text-purple-300 mt-1">Execute platform-wide maintenance operations</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-purple-900 dark:text-gray-200">
Runs all timer-triggered tasks plus additional backfill &amp; repair operations that only run on manual trigger.
</p>
<div className="space-y-3">
<div>
<p className="text-xs font-semibold text-purple-700 dark:text-purple-300 uppercase tracking-wide mb-1">Standard tasks (also run by 2h timer)</p>
<ul className="text-sm text-purple-900 dark:text-gray-200 space-y-1 ml-4">
<li className="flex items-start">
<span className="text-purple-500 dark:text-purple-400 mr-2">&bull;</span>
<span>Mark stalled sessions as timed out</span>
</li>
<li className="flex items-start">
<span className="text-purple-500 dark:text-purple-400 mr-2">&bull;</span>
<span>Aggregate metrics into historical snapshots</span>
</li>
<li className="flex items-start">
<span className="text-purple-500 dark:text-purple-400 mr-2">&bull;</span>
<span>Clean up old data based on retention policies</span>
</li>
<li className="flex items-start">
<span className="text-purple-500 dark:text-purple-400 mr-2">&bull;</span>
<span>Recompute platform-wide statistics</span>
</li>
</ul>
</div>
<div>
<p className="text-xs font-semibold text-amber-700 dark:text-amber-300 uppercase tracking-wide mb-1">Manual-only tasks (backfill &amp; repair)</p>
<ul className="text-sm text-purple-900 dark:text-gray-200 space-y-1 ml-4">
<li className="flex items-start">
<span className="text-amber-500 dark:text-amber-400 mr-2">&bull;</span>
<span>Backfill sessions missing from SessionsIndex</span>
</li>
<li className="flex items-start">
<span className="text-amber-500 dark:text-amber-400 mr-2">&bull;</span>
<span>Clean up ghost SessionsIndex entries <span className="text-xs text-gray-500 dark:text-gray-400">(remove after 2026-06)</span></span>
</li>
<li className="flex items-start">
<span className="text-amber-500 dark:text-amber-400 mr-2">&bull;</span>
<span>Backfill tenant OnboardedAt dates <span className="text-xs text-gray-500 dark:text-gray-400">(remove when complete)</span></span>
</li>
</ul>
</div>
</div>
<div className="bg-purple-50 dark:bg-gray-700 border border-purple-200 dark:border-purple-600 rounded-lg p-4">
<label className="block text-sm font-medium text-purple-800 dark:text-purple-200 mb-1">
Target Date (optional)
</label>
<input
type="date"
value={maintenanceDate}
onChange={(e) => setMaintenanceDate(e.target.value)}
max={maxMaintenanceDate}
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Leave empty to run the standard maintenance with automatic catch-up (aggregates any missed days within the last 7 days).
Select a specific date to manually aggregate metrics for that day, e.g. to backfill data older than 7 days.
</p>
</div>
<div className="bg-white dark:bg-gray-700 border border-purple-300 dark:border-purple-600 rounded-lg p-3">
<div className="flex items-start space-x-2">
<svg className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-sm text-gray-800 dark:text-gray-200">
<strong>Warning:</strong> This operation runs across all tenants and may take several minutes to complete.
Use this only for testing or when immediate cleanup is needed.
</p>
</div>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleTriggerMaintenance}
disabled={triggeringMaintenance}
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-violet-600 text-white rounded-lg hover:from-purple-700 hover:to-violet-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center space-x-2"
>
{triggeringMaintenance ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
<span>Running...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Run Now</span>
</>
)}
</button>
</div>
</div>
</div>
{/* Fetch & Reseed from GitHub */}
<div id="fetch-and-reseed" className="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-gray-800 dark:to-gray-800 border-2 border-emerald-300 dark:border-emerald-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-emerald-200 dark:border-emerald-700 bg-gradient-to-r from-emerald-100 to-teal-100 dark:from-emerald-900/40 dark:to-teal-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<div>
<h2 className="text-xl font-semibold text-emerald-900 dark:text-emerald-100">Fetch &amp; Reseed from GitHub</h2>
<p className="text-sm text-emerald-600 dark:text-emerald-300 mt-1">Fetch the latest rules from the GitHub repository and reseed all rule types</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-emerald-900 dark:text-gray-200">
This operation fetches all rule definitions from the GitHub repository and writes them to Azure Table Storage:
</p>
<ul className="text-sm text-emerald-900 dark:text-gray-200 space-y-1 ml-4">
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2">&bull;</span>
<span>Fetches Gather Rules, Analyze Rules, and IME Log Patterns from GitHub</span>
</li>
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2">&bull;</span>
<span>Deletes all existing global built-in entries and writes the latest versions</span>
</li>
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2">&bull;</span>
<span>Tenant-specific custom rules and overrides are not affected</span>
</li>
</ul>
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-3">
<div className="flex items-start space-x-2">
<svg className="w-5 h-5 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-blue-800 dark:text-blue-200">
Rules are loaded from <code className="px-1 bg-blue-100 dark:bg-blue-800 rounded text-xs">raw.githubusercontent.com</code>. After a merge to the repository, it may take up to <strong>5 minutes</strong>{" "}for GitHub&apos;s CDN cache to update. If you see stale rules after reseeding, wait a few minutes and try again.
</p>
</div>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleFetchFromGitHub}
disabled={fetchingFromGitHub}
className="px-6 py-3 bg-gradient-to-r from-emerald-600 to-teal-600 text-white rounded-lg hover:from-emerald-700 hover:to-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg flex items-center space-x-2"
>
{fetchingFromGitHub ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
<span>Fetching from GitHub...</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Fetch &amp; Reseed All</span>
</>
)}
</button>
</div>
</div>
</div>
{/* Individual Reseed Sections */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Reseed Analyze Rules */}
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border border-amber-200 dark:border-amber-700 rounded-lg shadow">
<div className="p-4 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40">
<h3 className="text-lg font-semibold text-amber-900 dark:text-amber-100">Reseed Analyze Rules</h3>
<p className="text-xs text-amber-600 dark:text-amber-300 mt-1">Fetch and reseed only analyze rules from GitHub</p>
</div>
<div className="p-4">
<div className="flex justify-end">
<button
onClick={handleReseedAnalyzeRules}
disabled={reseedingRules}
className="px-4 py-2 bg-gradient-to-r from-amber-600 to-orange-600 text-white rounded-lg hover:from-amber-700 hover:to-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow flex items-center space-x-2 text-sm"
>
{reseedingRules ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Reseeding...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Reseed Analyze</span>
</>
)}
</button>
</div>
</div>
</div>
{/* Reseed Gather Rules */}
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border border-amber-200 dark:border-amber-700 rounded-lg shadow">
<div className="p-4 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40">
<h3 className="text-lg font-semibold text-amber-900 dark:text-amber-100">Reseed Gather Rules</h3>
<p className="text-xs text-amber-600 dark:text-amber-300 mt-1">Fetch and reseed only gather rules from GitHub</p>
</div>
<div className="p-4">
<div className="flex justify-end">
<button
onClick={handleReseedGatherRules}
disabled={reseedingGatherRules}
className="px-4 py-2 bg-gradient-to-r from-amber-600 to-orange-600 text-white rounded-lg hover:from-amber-700 hover:to-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow flex items-center space-x-2 text-sm"
>
{reseedingGatherRules ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Reseeding...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Reseed Gather</span>
</>
)}
</button>
</div>
</div>
</div>
{/* Reseed IME Log Patterns */}
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border border-amber-200 dark:border-amber-700 rounded-lg shadow">
<div className="p-4 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40">
<h3 className="text-lg font-semibold text-amber-900 dark:text-amber-100">Reseed IME Log Patterns</h3>
<p className="text-xs text-amber-600 dark:text-amber-300 mt-1">Fetch and reseed only IME log patterns from GitHub</p>
</div>
<div className="p-4">
<div className="flex justify-end">
<button
onClick={handleReseedImePatterns}
disabled={reseedingImePatterns}
className="px-4 py-2 bg-gradient-to-r from-amber-600 to-orange-600 text-white rounded-lg hover:from-amber-700 hover:to-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow flex items-center space-x-2 text-sm"
>
{reseedingImePatterns ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Reseeding...</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Reseed IME</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
</>
);
}
@@ -3,6 +3,7 @@
import { useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { SectionCardHeader } from "@/components/SectionCardHeader";
interface MaintenanceTriggerSectionProps {
getAccessToken: () => Promise<string | null>;
@@ -87,17 +88,12 @@ export function MaintenanceTriggerSection({
return (
<div className="bg-gradient-to-br from-purple-50 to-violet-50 dark:from-gray-800 dark:to-gray-800 border-2 border-purple-300 dark:border-purple-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-purple-200 dark:border-purple-700 bg-gradient-to-r from-purple-100 to-violet-100 dark:from-purple-900/40 dark:to-violet-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-purple-900 dark:text-purple-100">Manual Maintenance Trigger</h2>
<p className="text-sm text-purple-600 dark:text-purple-300 mt-1">Execute platform-wide maintenance operations</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminPurple"
iconPath="M13 10V3L4 14h7v7l9-11h-7z"
title="Manual Maintenance Trigger"
subtitle="Execute platform-wide maintenance operations"
/>
<div className="p-6 space-y-4">
<p className="text-sm text-purple-900 dark:text-gray-200">
Runs all timer-triggered tasks plus additional backfill &amp; repair operations that only run on manual trigger.
@@ -6,6 +6,7 @@ import { ChannelEditor } from "@/components/notifications/ChannelEditor";
import { MAX_NOTIFICATION_CHANNELS, type NotificationChannel } from "@/app/settings/types";
import { toggleChannelBinding } from "./opsChannelRouting";
import { AUTO_ACTION_MODES, describeAutoActionWarning, type AutoActionMode } from "./excessiveEventAutoAction";
import { SectionCardHeader } from "@/components/SectionCardHeader";
// All known ops event types grouped by category
const OPS_EVENT_TYPES: Record<string, string[]> = {
@@ -351,19 +352,12 @@ export function OpsAlertRulesSection({
<div className="space-y-6">
{/* Alert Rules */}
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border-2 border-amber-300 dark:border-amber-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<div>
<h2 className="text-xl font-semibold text-amber-900 dark:text-amber-100">Alert Rules</h2>
<p className="text-sm text-amber-600 dark:text-amber-300 mt-1">
Select which ops events trigger alerts. {enabledRulesCount} rule(s) active, {enabledProviders} provider(s) enabled.
</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminAmber"
iconPath="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
title="Alert Rules"
subtitle={`Select which ops events trigger alerts. ${enabledRulesCount} rule(s) active, ${enabledProviders} provider(s) enabled.`}
/>
<div className="p-6">
{loadingConfig ? (
<div className="text-center py-8">
@@ -526,19 +520,12 @@ export function OpsAlertRulesSection({
{/* Channels */}
<div className="bg-gradient-to-br from-sky-50 to-cyan-50 dark:from-gray-800 dark:to-gray-800 border-2 border-sky-300 dark:border-sky-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-sky-200 dark:border-sky-700 bg-gradient-to-r from-sky-100 to-cyan-100 dark:from-sky-900/40 dark:to-cyan-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-sky-600 dark:text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-sky-900 dark:text-sky-100">Alert Channels</h2>
<p className="text-sm text-sky-600 dark:text-sky-300 mt-1">
Named destinations for platform alerts. Each rule above chooses which of these it notifies.
</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminSky"
iconPath="M13 10V3L4 14h7v7l9-11h-7z"
title="Alert Channels"
subtitle="Named destinations for platform alerts. Each rule above chooses which of these it notifies."
/>
<div className="p-6 space-y-4">
{channels.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
@@ -8,6 +8,7 @@ import TruncatedLabel from "@/components/TruncatedLabel";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { extractContinuation } from "@/lib/paginationLink";
import { extractSessionId, buildAutoReason } from "./opsEventSessionHelpers";
import { SectionCardHeader } from "@/components/SectionCardHeader";
interface OpsEvent {
id: string;
@@ -242,20 +243,13 @@ export function OpsEventsSection({
return (
<div className="bg-gradient-to-br from-slate-50 to-gray-50 dark:from-gray-800 dark:to-gray-800 border-2 border-slate-300 dark:border-slate-700 rounded-lg shadow-lg">
{/* Header */}
<div className="p-6 border-b border-slate-200 dark:border-slate-700 bg-gradient-to-r from-slate-100 to-gray-100 dark:from-slate-900/40 dark:to-gray-900/40">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-slate-600 dark:text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">Operational Events</h2>
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1">
Vital infrastructure events across consent, maintenance, security, and tenant lifecycle.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<SectionCardHeader
tone="adminSlate"
iconPath="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
title="Operational Events"
subtitle="Vital infrastructure events across consent, maintenance, security, and tenant lifecycle."
trailing={
<>
<Link
href="/admin/settings/alerts"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
@@ -272,9 +266,9 @@ export function OpsEventsSection({
>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
</div>
</div>
</>
}
/>
{/* Summary Cards */}
{!loading && filteredEvents.length > 0 && (
@@ -7,6 +7,7 @@ import { TenantConfiguration } from "./TenantManagementSection";
import { TenantSearchSelect } from "./TenantSearchSelect";
import { SessionExportEvent, generateCsvExport, generateUiExport } from "@/utils/sessionExportUtils";
import { trackEvent } from "@/lib/appInsights";
import { SectionCardHeader } from "@/components/SectionCardHeader";
function downloadFile(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
@@ -66,17 +67,12 @@ export function SessionExportSection({
return (
<div className="bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-gray-800 dark:to-gray-800 border-2 border-teal-300 dark:border-teal-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-teal-200 dark:border-teal-700 bg-gradient-to-r from-teal-100 to-cyan-100 dark:from-teal-900/40 dark:to-cyan-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-teal-600 dark:text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<div>
<h2 className="text-xl font-semibold text-teal-900 dark:text-teal-100">Session Event Export</h2>
<p className="text-sm text-teal-600 dark:text-teal-300 mt-1">Fetch and export all events directly from storage — use to analyze timeline phase grouping and ordering</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminTeal"
iconPath="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
title="Session Event Export"
subtitle="Fetch and export all events directly from storage — use to analyze timeline phase grouping and ordering"
/>
<div className="p-6">
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -10,6 +10,7 @@ import { isGuid } from "@/utils/inputValidation";
import { trackEvent } from "@/lib/appInsights";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import { SectionCardHeader } from "@/components/SectionCardHeader";
const PAGE_SIZE = 20;
@@ -367,21 +368,12 @@ function SessionReportsSectionInner({
return (
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 border-2 border-indigo-300 dark:border-indigo-700 rounded-lg shadow-lg">
{/* Section Header */}
<div className="p-6 border-b border-indigo-200 dark:border-indigo-700 bg-gradient-to-r from-indigo-100 to-purple-100 dark:from-indigo-900/40 dark:to-purple-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold text-indigo-900 dark:text-indigo-100">Session Reports</h2>
</div>
<p className="text-sm text-indigo-600 dark:text-indigo-300 mt-1">
Sessions reported by Tenant Admins for analysis
</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminIndigoPurple"
iconPath="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
title="Session Reports"
subtitle="Sessions reported by Tenant Admins for analysis"
/>
{/* Tenant filter + refresh */}
<div className="px-6 pt-4 pb-2 flex items-center gap-2 flex-wrap">
@@ -4,6 +4,7 @@ import { useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { SectionCardHeader } from "@/components/SectionCardHeader";
interface BlockedVersion {
versionPattern: string;
@@ -126,17 +127,12 @@ export function VersionBlockSection({
return (
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border-2 border-amber-300 dark:border-amber-700 rounded-lg shadow-lg">
<div className="p-6 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-amber-900 dark:text-amber-100">Version Block Management</h2>
<p className="text-sm text-amber-600 dark:text-amber-300 mt-1">Block or kill agents by version pattern. Useful for catching old agents that are still running.</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminAmber"
iconPath="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
title="Version Block Management"
subtitle="Block or kill agents by version pattern. Useful for catching old agents that are still running."
/>
<div className="p-6 space-y-6">
{/* Pattern help */}
<div className="border border-amber-200 dark:border-amber-700 rounded-lg p-4 bg-amber-50/50 dark:bg-amber-900/10">
@@ -7,6 +7,7 @@ import SaveResetBar from "../../settings/components/SaveResetBar";
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
import type { AdminConfiguration } from "@/types/adminConfig";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { SectionCardHeader } from "@/components/SectionCardHeader";
interface VulnerabilityTopCve {
cveId: string;
@@ -422,17 +423,12 @@ export function VulnerabilityDataSection({
return (
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-gray-800 dark:to-gray-800 border-2 border-emerald-300 dark:border-emerald-700 rounded-lg shadow-lg">
{/* Header */}
<div className="p-6 border-b border-emerald-200 dark:border-emerald-700 bg-gradient-to-r from-emerald-100 to-teal-100 dark:from-emerald-900/40 dark:to-teal-900/40">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-emerald-900 dark:text-emerald-100">Vulnerability Data Configuration</h2>
<p className="text-sm text-emerald-600 dark:text-emerald-300 mt-1">Configure vulnerability correlation data sources and API keys.</p>
</div>
</div>
</div>
<SectionCardHeader
tone="adminEmerald"
iconPath="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
title="Vulnerability Data Configuration"
subtitle="Configure vulnerability correlation data sources and API keys."
/>
{/* Content */}
<div className="p-6">
@@ -7,6 +7,7 @@ import { useTenantList } from "@/hooks/useTenantList";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
import { SectionCardHeader } from "@/components/SectionCardHeader";
/** One delegated-admin assignment as returned by /api/global/delegated-admins (camelCase JSON). */
interface DelegatedAssignment {
@@ -191,19 +192,12 @@ export function SectionDelegatedAdmins() {
return (
<div className="bg-white rounded-lg shadow">
{/* Header */}
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-sky-50 to-indigo-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 21a9 9 0 100-18 9 9 0 000 18zm0 0c2.5 0 4.5-4 4.5-9S14.5 3 12 3 7.5 7 7.5 12s2 9 4.5 9zM3 12h18" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Delegated Admins (MSP Mode)</h2>
</div>
<p className="mt-1 text-sm text-gray-600">
Grant a person read access to a subset of tenants (a &ldquo;fleet&rdquo;), without making them a
full platform admin. They sign in to their own tenant and see only the tenants you grant here.
</p>
</div>
<SectionCardHeader
tone="skyIndigo"
iconPath="M12 21a9 9 0 100-18 9 9 0 000 18zm0 0c2.5 0 4.5-4 4.5-9S14.5 3 12 3 7.5 7 7.5 12s2 9 4.5 9zM3 12h18"
title="Delegated Admins (MSP Mode)"
subtitle="Grant a person read access to a subset of tenants (a “fleet”), without making them a full platform admin. They sign in to their own tenant and see only the tenants you grant here."
/>
<div className="p-6 space-y-4">
{/* Notifications */}
@@ -7,6 +7,7 @@ import { useTenantList } from "@/hooks/useTenantList";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
import { SectionCardHeader } from "@/components/SectionCardHeader";
/** One UPN assigned to a group (camelCase JSON from the backend). */
interface GroupAssignee {
@@ -245,19 +246,12 @@ export function SectionTenantGroups() {
return (
<div className="bg-white rounded-lg shadow">
{/* Header */}
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-sky-50 to-indigo-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Tenant Groups (MSP Mode)</h2>
</div>
<p className="mt-1 text-sm text-gray-600">
Bundle tenants into a named group, then assign people to the group instead of to each tenant.
Add a tenant once and every assignee gets it; manage your managed-service team by who is assigned here.
</p>
</div>
<SectionCardHeader
tone="skyIndigo"
iconPath="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
title="Tenant Groups (MSP Mode)"
subtitle="Bundle tenants into a named group, then assign people to the group instead of to each tenant. Add a tenant once and every assignee gets it; manage your managed-service team by who is assigned here."
/>
<div className="p-6 space-y-4">
{/* Notifications */}
@@ -31,7 +31,8 @@ function validateEvaluateOnForm(form: RuleForm): string | null {
import AnalyzeRuleFormFields from "./components/AnalyzeRuleFormFields";
import AnalyzeRuleCard, { RuleTrendPoint, RuleRegressionInfo } from "./components/AnalyzeRuleCard";
import TemplateConfigModal from "./components/TemplateConfigModal";
import { DOCS_URL } from "@/utils/config";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
export default function AnalyzeRulesPage() {
const { user, getAccessToken } = useAuth();
@@ -768,16 +769,13 @@ export default function AnalyzeRulesPage() {
{/* Create Custom Rule Form */}
{showCreateForm && (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-purple-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Create Custom Analyze Rule</h2>
<p className="text-sm text-gray-500 mt-1">Define conditions and confidence scoring for issue detection — <a href={`${DOCS_URL}/rules/analyze-rules`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:text-green-800 underline">see documentation</a></p>
</div>
</div>
{/* JSON Mode Toggle */}
<SectionCardHeader
tone="indigoPurple"
iconPath="M12 6v6m0 0v6m0-6h6m-6 0H6"
title="Create Custom Analyze Rule"
subtitle="Define conditions and confidence scoring for issue detection"
docsPath={DOCS_PATHS.analyzeRules}
trailing={
<JsonModeToggleButtons
jsonMode={jsonModeCreate}
onToggleMode={(mode) => {
@@ -786,8 +784,8 @@ export default function AnalyzeRulesPage() {
setJsonError(null);
}}
/>
</div>
</div>
}
/>
<div className="p-6">
<FormJsonToggle
jsonMode={jsonModeCreate}
@@ -17,7 +17,8 @@ import { TenantScopeSelector } from "@/components/TenantScopeSelector";
import { GatherRule, NewRuleForm, PastedGatherJson, EMPTY_FORM, PHASE_TRIGGERS, buildScopeFields, gatherRuleToForm, targetBlocked, validateScopeSelection } from "./types";
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
import { GatherRuleCard } from "./components/GatherRuleCard";
import { DOCS_URL } from "@/utils/config";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
export default function GatherRulesPage() {
const { user, getAccessToken } = useAuth();
@@ -484,17 +485,13 @@ export default function GatherRulesPage() {
{/* Create Custom Rule Form */}
{showCreateForm && (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-purple-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Create Custom Rule</h2>
<p className="text-sm text-gray-500 mt-1">Define a new data collection rule for enrolled devices — <a href={`${DOCS_URL}/rules/gather-rules`} target="_blank" rel="noopener noreferrer" className="text-green-700 hover:text-green-800 underline">see documentation</a></p>
</div>
</div>
<SectionCardHeader
tone="indigoPurple"
iconPath="M12 6v6m0 0v6m0-6h6m-6 0H6"
title="Create Custom Rule"
subtitle="Define a new data collection rule for enrolled devices"
docsPath={DOCS_PATHS.gatherRules}
trailing={
<JsonModeToggleButtons
jsonMode={jsonModeCreate}
onToggleMode={(mode) => {
@@ -503,8 +500,8 @@ export default function GatherRulesPage() {
setJsonError(null);
}}
/>
</div>
</div>
}
/>
<div className="p-6">
<FormJsonToggle
jsonMode={jsonModeCreate}
@@ -1,6 +1,8 @@
"use client";
import { TenantAdmin } from "../types";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface AdminManagementSectionProps {
admins: TenantAdmin[];
@@ -75,17 +77,13 @@ export default function AdminManagementSection({
}: AdminManagementSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-purple-50 to-indigo-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Access Management</h2>
<p className="text-sm text-gray-500 mt-1">Manage team members and their roles for this tenant</p>
</div>
</div>
</div>
<SectionCardHeader
tone="purple"
iconPath="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
title="Access Management"
subtitle="Manage team members and their roles for this tenant"
docsPath={DOCS_PATHS.accessManagement}
/>
<div className="p-6 space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
@@ -2,6 +2,8 @@
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface AgentAnalyzersSectionProps {
enableLocalAdminAnalyzer: boolean;
@@ -73,17 +75,13 @@ export default function AgentAnalyzersSection({
return (
<div id="agent-analyzers" className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-rose-50 to-red-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-rose-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Agent Analyzers</h2>
<p className="text-sm text-gray-500 mt-1">Security analyzers that run on enrolled devices to detect configuration anomalies.</p>
</div>
</div>
</div>
<SectionCardHeader
tone="rose"
iconPath="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
title="Agent Analyzers"
subtitle="Security analyzers that run on enrolled devices to detect configuration anomalies."
docsPath={DOCS_PATHS.agentAnalyzers}
/>
<div className="p-6 space-y-6">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-6">
@@ -2,6 +2,8 @@
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface AgentSettingsSectionProps {
enablePerformanceCollector: boolean;
@@ -101,17 +103,13 @@ export default function AgentSettingsSection({
<>
{/* Agent Parameters */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-violet-50 to-purple-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Agent Parameters</h2>
<p className="text-sm text-gray-500 mt-1">Control agent behavior on enrolled devices. Changes take effect on the next agent config refresh.</p>
</div>
</div>
</div>
<SectionCardHeader
tone="violet"
iconPath="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
title="Agent Parameters"
subtitle="Control agent behavior on enrolled devices. Changes take effect on the next agent config refresh."
docsPath={DOCS_PATHS.agentParameters}
/>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
@@ -361,17 +359,13 @@ export default function AgentSettingsSection({
{/* Agent Collectors */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-emerald-50 to-teal-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Agent Collectors</h2>
<p className="text-sm text-gray-500 mt-1">Enable optional data collectors on enrolled devices. These generate additional telemetry traffic.</p>
</div>
</div>
</div>
<SectionCardHeader
tone="emerald"
iconPath="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
title="Agent Collectors"
subtitle="Enable optional data collectors on enrolled devices. These generate additional telemetry traffic."
docsPath={DOCS_PATHS.agentCollectors}
/>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly} notice={false}>
<div className="space-y-5">
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface AutopilotValidationSectionProps {
validateAutopilotDevice: boolean;
@@ -120,26 +122,18 @@ export default function AutopilotValidationSection({
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">
Enrollment Device Validation
</h2>
<p className="text-sm text-gray-500 mt-1">
Validate devices against Intune registrations before accepting agent data (mandatory for agent ingestion)
</p>
</div>
</div>
<SectionCardHeader
tone="amber"
iconPath="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
title="Enrollment Device Validation"
subtitle="Validate devices against Intune registrations before accepting agent data (mandatory for agent ingestion)"
docsPath={DOCS_PATHS.enrollmentDeviceValidation}
trailing={
<span className={`flex-shrink-0 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${anyValidationEnabled ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{anyValidationEnabled ? "Enabled" : "Disabled"}
</span>
</div>
</div>
}
/>
<div className="p-6 space-y-5">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 space-y-2">
<p className="text-sm text-gray-700">
@@ -2,6 +2,8 @@
import { useState } from "react";
import { BOOTSTRAP_GO_URL } from "@/utils/config";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
// Display-only: the authoritative URL comes from the backend's bootstrapUrl
// (Constants.BootstrapGoBaseUrl); this host renders the sessions list rows.
@@ -131,34 +133,13 @@ export default function BootstrapSessionsSection({
return (
<div className="bg-white rounded-lg shadow">
{/* Header */}
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-cyan-50 to-teal-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-2 bg-cyan-100 rounded-lg">
<svg
className="w-6 h-6 text-cyan-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
OOBE Bootstrap
</h3>
<p className="text-sm text-gray-500">
Generate time-limited URLs to install the agent before Intune
enrollment
</p>
</div>
</div>
<SectionCardHeader
tone="cyan"
iconPath="M13 10V3L4 14h7v7l9-11h-7z"
title="OOBE Bootstrap"
subtitle="Generate time-limited URLs to install the agent before Intune enrollment"
docsPath={DOCS_PATHS.bootstrapSessions}
trailing={
<button
onClick={onRefresh}
disabled={loading}
@@ -179,8 +160,8 @@ export default function BootstrapSessionsSection({
/>
</svg>
</button>
</div>
</div>
}
/>
<div className="p-6 space-y-4">
{/* Info banner */}
@@ -2,6 +2,8 @@
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface DataManagementSectionProps {
dataRetentionDays: number;
@@ -35,17 +37,13 @@ export default function DataManagementSection({
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-green-50 to-emerald-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Data Management</h2>
<p className="text-sm text-gray-500 mt-1">Configure data retention and session timeout policies</p>
</div>
</div>
</div>
<SectionCardHeader
tone="green"
iconPath="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
title="Data Management"
subtitle="Configure data retention and session timeout policies"
docsPath={DOCS_PATHS.dataManagement}
/>
<div className="p-6 space-y-6">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-6">
@@ -10,6 +10,8 @@ import { ContextPill, DiagnosticsPathRow } from "@/components/diagnostics/Diagno
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { parseSasExpiry } from "./diagnosticsSasExpiry";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
// Re-export for callers that still import from the section. Implementation lives
// in `diagnosticsSasExpiry.ts` so vitest can test it without pulling in JSX.
@@ -89,17 +91,13 @@ export default function DiagnosticsSection({
return (
<div id="diagnostics" className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Diagnostics Package</h2>
<p className="text-sm text-gray-500 mt-1">Upload diagnostic files as a ZIP package to your Azure Blob Storage after enrollment.</p>
</div>
</div>
</div>
<SectionCardHeader
tone="amber"
iconPath="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
title="Diagnostics Package"
subtitle="Upload diagnostic files as a ZIP package to your Azure Blob Storage after enrollment."
docsPath={DOCS_PATHS.diagnosticsPackage}
/>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
@@ -4,6 +4,8 @@ import { useState } from "react";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { joinList, parseList } from "../lib/hardwareWhitelist";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface HardwareWhitelistSectionProps {
manufacturerWhitelist: string;
@@ -136,22 +138,13 @@ export default function HardwareWhitelistSection({
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-blue-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">
Hardware Whitelist
</h2>
<p className="text-sm text-gray-500 mt-1">
Control which device manufacturers and models the backend accepts
monitoring data from
</p>
</div>
</div>
</div>
<SectionCardHeader
tone="indigo"
iconPath="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
title="Hardware Whitelist"
subtitle="Control which device manufacturers and models the backend accepts monitoring data from"
docsPath={DOCS_PATHS.hardwareWhitelist}
/>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-5">
@@ -7,6 +7,8 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
import { api } from "@/lib/api";
import { useTenantList } from "@/hooks/useTenantList";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface McpUser {
upn: string;
@@ -286,18 +288,13 @@ export default function McpUsersSection() {
return (
<div className="bg-white rounded-lg shadow">
{/* Header */}
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-purple-50 to-indigo-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">MCP User Management</h2>
</div>
<p className="mt-1 text-sm text-gray-600">
Control who can access the AI agent interface (Model Context Protocol) and assign individual usage plans.
</p>
</div>
<SectionCardHeader
tone="purple"
iconPath="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
title="MCP User Management"
subtitle="Control who can access the AI agent interface (Model Context Protocol) and assign individual usage plans."
docsPath={DOCS_PATHS.mcpUsers}
/>
<div className="p-6 space-y-4">
{/* Notifications */}
@@ -4,6 +4,8 @@ import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { MAX_NOTIFICATION_CHANNELS, NotificationChannel } from "../types";
import { ChannelEditor } from "@/components/notifications/ChannelEditor";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface NotificationsSectionProps {
channels: NotificationChannel[];
@@ -51,19 +53,13 @@ export default function NotificationsSection({
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-sky-50 to-blue-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Notifications</h2>
<p className="text-sm text-gray-500 mt-1">
Send notifications to one or more channels — Teams, Slack, Discord, or any JSON webhook (ticketing, automation, bots). Each channel picks which events it receives.
</p>
</div>
</div>
</div>
<SectionCardHeader
tone="sky"
iconPath="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
title="Notifications"
subtitle="Send notifications to one or more channels — Teams, Slack, Discord, or any JSON webhook (ticketing, automation, bots). Each channel picks which events it receives."
docsPath={DOCS_PATHS.notifications}
/>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
@@ -3,6 +3,8 @@
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface OffboardingInProgressInfo {
status: string; // "Queued" | "Initiated" | "InProgress" | "Completed" | "Failed"
@@ -118,17 +120,13 @@ export default function OffboardingSection({
<>
{/* Danger Zone: Tenant Offboarding */}
<div className="bg-white rounded-lg shadow border-2 border-red-200">
<div className="p-6 border-b border-red-100 bg-red-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-red-900">Danger Zone</h2>
<p className="text-sm text-red-600 mt-1">Irreversible and destructive actions</p>
</div>
</div>
</div>
<SectionCardHeader
tone="danger"
iconPath="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
title="Danger Zone"
subtitle="Irreversible and destructive actions"
docsPath={DOCS_PATHS.dangerZone}
/>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface UnrestrictedModeSectionProps {
unrestrictedMode: boolean;
@@ -34,17 +36,13 @@ export default function UnrestrictedModeSection({
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h2 className="text-lg font-semibold text-amber-900">Unrestricted Mode</h2>
</div>
<p className="text-sm text-amber-700 mt-1">
Disable agent guardrails for advanced data collection
</p>
</div>
<SectionCardHeader
tone="warning"
iconPath="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
title="Unrestricted Mode"
subtitle="Disable agent guardrails for advanced data collection"
docsPath={DOCS_PATHS.unrestrictedMode}
/>
<div className="p-6 space-y-4">
{/* Active warning banner */}
@@ -5,6 +5,8 @@ import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../../../../contexts/AuthContext";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
import { DocsLink } from "@/components/DocsLink";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface UsageRecord {
userId: string;
@@ -177,6 +179,7 @@ export function SectionMcpUsage() {
>
{loading ? "Loading..." : "Refresh"}
</button>
<DocsLink path={DOCS_PATHS.mcpUsage} />
</div>
</div>
@@ -3,6 +3,8 @@
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
export function SectionContact() {
const {
@@ -17,17 +19,13 @@ export function SectionContact() {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-blue-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Contact</h2>
<p className="text-sm text-gray-500 mt-1">Where we reach you about the service</p>
</div>
</div>
</div>
<SectionCardHeader
tone="indigo"
iconPath="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
title="Contact"
subtitle="Where we reach you about the service"
docsPath={DOCS_PATHS.contact}
/>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={!canEditConfig}>
@@ -6,6 +6,8 @@ import { useTenantConfig } from "../../TenantConfigContext";
import { trialDaysLeft } from "@/lib/edition";
import { PlanCards } from "@/components/plans/PlanCards";
import { SITE_URL } from "@/utils/config";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
/**
* Self-service Pro trial switch. The Pro feature set is not finalized yet, so the
@@ -135,17 +137,13 @@ export function SectionPlan() {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-purple-50 to-indigo-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Plan</h2>
<p className="text-sm text-gray-500 mt-1">Your current plan and what Pro adds</p>
</div>
</div>
</div>
<SectionCardHeader
tone="purple"
iconPath="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
title="Plan"
subtitle="Your current plan and what Pro adds"
docsPath={DOCS_PATHS.plan}
/>
<div className="p-6">
<PlanCards
@@ -4,6 +4,8 @@ import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
import { trackEvent } from "@/lib/appInsights";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
export function SectionSlaTargets() {
const {
@@ -23,21 +25,13 @@ export function SectionSlaTargets() {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-blue-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">
SLA Targets
</h2>
<p className="text-sm text-gray-500 mt-1">
SLA targets and breach notification settings
</p>
</div>
</div>
</div>
<SectionCardHeader
tone="indigo"
iconPath="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
title="SLA Targets"
subtitle="SLA targets and breach notification settings"
docsPath={DOCS_PATHS.slaTargets}
/>
<div className="p-6 space-y-8">
<ReadOnlyFieldset readOnly={!canEditConfig}>
<div className="space-y-8">
@@ -0,0 +1,29 @@
import { DOCS_URL } from "@/utils/config";
interface DocsLinkProps {
/** Path below the published docs root, with a leading slash (e.g. "/reference/settings#notifications"). */
path: string;
label?: string;
className?: string;
}
/**
* Small "Read the docs" link that opens the matching customer documentation page in a new tab.
* Neutral colouring so it sits on every section-header tone, including the red danger zone.
*/
export function DocsLink({ path, label = "Read the docs", className = "" }: DocsLinkProps) {
return (
<a
href={`${DOCS_URL}${path}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`${label} (opens in a new tab)`}
className={`inline-flex flex-shrink-0 items-center gap-1 whitespace-nowrap text-xs font-medium text-gray-500 hover:text-gray-900 hover:underline underline-offset-2 dark:text-gray-400 dark:hover:text-gray-100 ${className}`}
>
{label}
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
);
}
@@ -0,0 +1,189 @@
import type { ReactNode } from "react";
import { DocsLink } from "./DocsLink";
/**
* Colour tones for section card headers. Every class string is literal so Tailwind can see it —
* never assemble these from fragments. Tenant tones use the pastel 50-level gradient with grey
* text; admin tones use the 100-level gradient, tinted text, and dark-mode variants.
*/
const TONES = {
amber: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50",
icon: "w-6 h-6 text-amber-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
violet: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-violet-50 to-purple-50",
icon: "w-6 h-6 text-violet-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
emerald: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-emerald-50 to-teal-50",
icon: "w-6 h-6 text-emerald-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
rose: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-rose-50 to-red-50",
icon: "w-6 h-6 text-rose-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
indigo: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-blue-50",
icon: "w-6 h-6 text-indigo-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
indigoPurple: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-purple-50",
icon: "w-6 h-6 text-indigo-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
sky: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-sky-50 to-blue-50",
icon: "w-6 h-6 text-sky-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
skyIndigo: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-sky-50 to-indigo-50",
icon: "w-6 h-6 text-sky-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
purple: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-purple-50 to-indigo-50",
icon: "w-6 h-6 text-purple-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
green: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-green-50 to-emerald-50",
icon: "w-6 h-6 text-green-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
cyan: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-cyan-50 to-teal-50",
icon: "w-6 h-6 text-cyan-600",
title: "text-xl font-semibold text-gray-900",
subtitle: "text-sm text-gray-500 mt-1",
},
/** Amber gradient with amber text — guardrail-relaxing settings (Unrestricted Mode). */
warning: {
wrapper: "p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50",
icon: "w-6 h-6 text-amber-600",
title: "text-xl font-semibold text-amber-900",
subtitle: "text-sm text-amber-700 mt-1",
},
/** Flat red — irreversible actions (Danger Zone). */
danger: {
wrapper: "p-6 border-b border-red-100 bg-red-50",
icon: "w-6 h-6 text-red-600",
title: "text-xl font-semibold text-red-900",
subtitle: "text-sm text-red-600 mt-1",
},
adminIndigo: {
wrapper: "p-6 border-b border-indigo-200 dark:border-indigo-700 bg-gradient-to-r from-indigo-100 to-blue-100 dark:from-indigo-900/40 dark:to-blue-900/40",
icon: "w-6 h-6 text-indigo-600 dark:text-indigo-400",
title: "text-xl font-semibold text-indigo-900 dark:text-indigo-100",
subtitle: "text-sm text-indigo-600 dark:text-indigo-300 mt-1",
},
adminIndigoPurple: {
wrapper: "p-6 border-b border-indigo-200 dark:border-indigo-700 bg-gradient-to-r from-indigo-100 to-purple-100 dark:from-indigo-900/40 dark:to-purple-900/40",
icon: "w-6 h-6 text-indigo-600 dark:text-indigo-400",
title: "text-xl font-semibold text-indigo-900 dark:text-indigo-100",
subtitle: "text-sm text-indigo-600 dark:text-indigo-300 mt-1",
},
adminEmerald: {
wrapper: "p-6 border-b border-emerald-200 dark:border-emerald-700 bg-gradient-to-r from-emerald-100 to-teal-100 dark:from-emerald-900/40 dark:to-teal-900/40",
icon: "w-6 h-6 text-emerald-600 dark:text-emerald-400",
title: "text-xl font-semibold text-emerald-900 dark:text-emerald-100",
subtitle: "text-sm text-emerald-600 dark:text-emerald-300 mt-1",
},
adminAmber: {
wrapper: "p-6 border-b border-amber-200 dark:border-amber-700 bg-gradient-to-r from-amber-100 to-orange-100 dark:from-amber-900/40 dark:to-orange-900/40",
icon: "w-6 h-6 text-amber-600 dark:text-amber-400",
title: "text-xl font-semibold text-amber-900 dark:text-amber-100",
subtitle: "text-sm text-amber-600 dark:text-amber-300 mt-1",
},
adminRed: {
wrapper: "p-6 border-b border-red-200 dark:border-red-700 bg-gradient-to-r from-red-100 to-orange-100 dark:from-red-900/40 dark:to-orange-900/40",
icon: "w-6 h-6 text-red-600 dark:text-red-400",
title: "text-xl font-semibold text-red-900 dark:text-red-100",
subtitle: "text-sm text-red-600 dark:text-red-300 mt-1",
},
adminTeal: {
wrapper: "p-6 border-b border-teal-200 dark:border-teal-700 bg-gradient-to-r from-teal-100 to-cyan-100 dark:from-teal-900/40 dark:to-cyan-900/40",
icon: "w-6 h-6 text-teal-600 dark:text-teal-400",
title: "text-xl font-semibold text-teal-900 dark:text-teal-100",
subtitle: "text-sm text-teal-600 dark:text-teal-300 mt-1",
},
adminPurple: {
wrapper: "p-6 border-b border-purple-200 dark:border-purple-700 bg-gradient-to-r from-purple-100 to-violet-100 dark:from-purple-900/40 dark:to-violet-900/40",
icon: "w-6 h-6 text-purple-600 dark:text-purple-400",
title: "text-xl font-semibold text-purple-900 dark:text-purple-100",
subtitle: "text-sm text-purple-600 dark:text-purple-300 mt-1",
},
adminSky: {
wrapper: "p-6 border-b border-sky-200 dark:border-sky-700 bg-gradient-to-r from-sky-100 to-cyan-100 dark:from-sky-900/40 dark:to-cyan-900/40",
icon: "w-6 h-6 text-sky-600 dark:text-sky-400",
title: "text-xl font-semibold text-sky-900 dark:text-sky-100",
subtitle: "text-sm text-sky-600 dark:text-sky-300 mt-1",
},
adminSlate: {
wrapper: "p-6 border-b border-slate-200 dark:border-slate-700 bg-gradient-to-r from-slate-100 to-gray-100 dark:from-slate-900/40 dark:to-gray-900/40",
icon: "w-6 h-6 text-slate-600 dark:text-slate-400",
title: "text-xl font-semibold text-slate-900 dark:text-slate-100",
subtitle: "text-sm text-slate-600 dark:text-slate-300 mt-1",
},
} as const;
export type SectionCardTone = keyof typeof TONES;
export interface SectionCardHeaderProps {
tone: SectionCardTone;
/** The `d` attribute of a single 24x24 Heroicons outline path. */
iconPath: string;
title: string;
subtitle?: ReactNode;
/** Docs path below the published docs root; renders a "Read the docs" link on the right. */
docsPath?: string;
/** Badge, button, or toggle group rendered on the right, before the docs link. */
trailing?: ReactNode;
}
/**
* Header strip of a settings/admin card: icon, title, subtitle on the left; optional trailing
* controls and docs link on the right. Renders only the header — the card wrapper stays with the
* caller so ids, refs, and borders remain where they are.
*/
export function SectionCardHeader({ tone, iconPath, title, subtitle, docsPath, trailing }: SectionCardHeaderProps) {
const t = TONES[tone];
const hasRight = trailing !== undefined || docsPath !== undefined;
return (
<div className={t.wrapper}>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center space-x-2 min-w-0">
<svg className={`${t.icon} flex-shrink-0`} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={iconPath} />
</svg>
<div className="min-w-0">
<h2 className={t.title}>{title}</h2>
{subtitle !== undefined && <p className={t.subtitle}>{subtitle}</p>}
</div>
</div>
{hasRight && (
<div className="flex flex-shrink-0 items-center gap-3">
{trailing}
{docsPath !== undefined && <DocsLink path={docsPath} />}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { SectionCardHeader } from "../SectionCardHeader";
import { DocsLink } from "../DocsLink";
import { DOCS_URL } from "@/utils/config";
const ICON = "M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4";
describe("SectionCardHeader", () => {
it("renders tone classes, icon path, title and subtitle", () => {
const html = renderToStaticMarkup(
createElement(SectionCardHeader, {
tone: "amber",
iconPath: ICON,
title: "Diagnostics Package",
subtitle: "Upload diagnostic files after enrollment.",
}),
);
expect(html).toContain("from-amber-50 to-orange-50");
expect(html).toContain("text-amber-600");
expect(html).toContain(`d="${ICON}"`);
expect(html).toContain("<h2 class=\"text-xl font-semibold text-gray-900\">Diagnostics Package</h2>");
expect(html).toContain("Upload diagnostic files after enrollment.");
// No right-hand group when neither docsPath nor trailing is given.
expect(html).not.toContain("gap-3");
});
it("renders the docs link as a new-tab link below DOCS_URL", () => {
const html = renderToStaticMarkup(
createElement(SectionCardHeader, {
tone: "sky",
iconPath: ICON,
title: "Notifications",
docsPath: "/integrations/notifications",
}),
);
expect(html).toContain(`href="${DOCS_URL}/integrations/notifications"`);
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
expect(html).toContain("Read the docs");
});
it("renders trailing content before the docs link", () => {
const html = renderToStaticMarkup(
createElement(SectionCardHeader, {
tone: "danger",
iconPath: ICON,
title: "Danger Zone",
docsPath: "/reference/settings#danger-zone",
trailing: createElement("span", { id: "badge" }, "Enabled"),
}),
);
expect(html).toContain("bg-red-50");
expect(html).toContain("text-red-900");
expect(html.indexOf('id="badge"')).toBeGreaterThan(-1);
expect(html.indexOf('id="badge"')).toBeLessThan(html.indexOf("Read the docs"));
});
it("admin tones carry dark-mode variants", () => {
const html = renderToStaticMarkup(
createElement(SectionCardHeader, { tone: "adminAmber", iconPath: ICON, title: "Alert Rules" }),
);
expect(html).toContain("dark:from-amber-900/40");
expect(html).toContain("dark:text-amber-100");
});
});
describe("DocsLink", () => {
it("labels the new-tab behaviour for assistive tech", () => {
const html = renderToStaticMarkup(createElement(DocsLink, { path: "/plans", label: "See plans" }));
expect(html).toContain(`href="${DOCS_URL}/plans"`);
expect(html).toContain('aria-label="See plans (opens in a new tab)"');
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { DOCS_PATHS, DOCS_TOP_LEVEL_SEGMENTS } from "../docsPaths";
/**
* GitBook publishes the first URL segment from the SUMMARY.md group heading, not the folder
* name (`troubleshooting/` on disk is `/troubleshooting-and-support/` on the web). A path written
* from the repo layout 404s silently in production — this test makes that a CI failure.
*/
describe("DOCS_PATHS", () => {
const entries = Object.entries(DOCS_PATHS);
it("every path is root-relative, lowercase, with an optional single anchor", () => {
for (const [key, path] of entries) {
expect(path, key).toMatch(/^\/[a-z0-9-]+(\/[a-z0-9-]+)*(#[a-z0-9-]+)?$/);
expect(path, key).not.toMatch(/\.md/);
expect(path, key).not.toMatch(/--/);
}
});
it("every path starts with a published top-level segment", () => {
const allowed = new Set<string>(DOCS_TOP_LEVEL_SEGMENTS);
for (const [key, path] of entries) {
const first = path.slice(1).split(/[/#]/)[0];
expect(allowed.has(first), `${key}: "${first}" is not a published docs section`).toBe(true);
}
});
it("does not use on-disk folder names that GitBook renames", () => {
for (const [key, path] of entries) {
expect(path, key).not.toMatch(/^\/(troubleshooting|trust)\//);
}
});
});
@@ -0,0 +1,47 @@
/**
* Documentation page for each portal section that links to the customer docs, as a path below DOCS_URL.
*
* GitBook publishes the first path segment as the slugified SUMMARY.md group heading, not the
* folder name: `troubleshooting/` → `/troubleshooting-and-support/`, `trust/` → `/trust-and-security/`.
* Anchors are GitBook heading slugs (single hyphens). `docsPaths.test.ts` guards the shape.
*/
export const DOCS_PATHS = {
// Settings → Tenant
enrollmentDeviceValidation: "/reference/settings#enrollment-device-validation",
hardwareWhitelist: "/reference/settings#hardware-whitelist",
notifications: "/integrations/notifications",
accessManagement: "/concepts/roles-and-permissions",
slaTargets: "/portal-guide/sla-compliance#configuring-targets",
plan: "/plans",
contact: "/reference/settings#contact",
bootstrapSessions: "/reference/bootstrap-script-and-tokens",
mcpUsers: "/integrations/ai-integration-mcp",
// Settings → Agent
agentParameters: "/reference/settings#agent-parameters",
agentCollectors: "/reference/settings#agent-collectors",
agentAnalyzers: "/reference/settings#agent-analyzers",
diagnosticsPackage: "/troubleshooting-and-support/diagnostics-and-log-collection",
unrestrictedMode: "/reference/settings#unrestricted-mode-pro-plan-on-request",
// Settings → Maintenance
dataManagement: "/reference/settings#data-management",
dangerZone: "/reference/settings#danger-zone",
// Settings → Reporting
mcpUsage: "/integrations/ai-integration-mcp#rate-limits-and-usage-plans",
// Rules
analyzeRules: "/rules/analyze-rules",
gatherRules: "/rules/gather-rules",
} as const;
/** Top-level URL segments GitBook publishes for docs.autopilotmonitor.com (SUMMARY.md groups + root pages). */
export const DOCS_TOP_LEVEL_SEGMENTS = [
"getting-started",
"concepts",
"portal-guide",
"rules",
"integrations",
"reference",
"trust-and-security",
"troubleshooting-and-support",
"changelog",
"plans",
] as const;