mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e33d85c01d | |||
| 4e73ba8900 | |||
| 4df52b3da9 | |||
| 844d86410c | |||
| 9514e1033d | |||
| b2236bf65f | |||
| 31535b07e0 | |||
| 066b6e6f04 | |||
| a1e2b76a9f | |||
| 29e5b8251d | |||
| 02168a5c55 | |||
| a6b6661320 | |||
| 0c72666a7a | |||
| 9023321db9 | |||
| 7ef097529c | |||
| e7df69efe4 | |||
| 2c7efa2733 | |||
| 2742549827 | |||
| 3c45f5cb09 | |||
| d5d1d7bfbd | |||
| f4e2088b69 | |||
| 55425058a8 | |||
| eec0d17607 | |||
| 43f5304ba4 | |||
| b21472dd8c | |||
| e3991d55be | |||
| 864d1ed31e | |||
| 76ef1e53ca | |||
| 7dd298f04e |
+1
-1
Submodule .claude/commands updated: 9b48783b26...e57da4e431
@@ -40,6 +40,23 @@ jobs:
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
|
||||
# The customer docs repo is the single source of the "What's new" panel: the prebuild
|
||||
# step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into
|
||||
# public/whats-new.json and dates every bullet via git blame — hence the full history.
|
||||
# Public repo, no token. Editing the docs does NOT update the live panel until this
|
||||
# workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json
|
||||
# records which docs commit is live.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: whats-new-docs
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve docs commit
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
@@ -51,7 +68,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
# next.config output:'export' -- `next build` writes the static site to out/
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version).
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
|
||||
- name: Build
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
env:
|
||||
@@ -68,9 +85,12 @@ jobs:
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
# the verify step below polls the deployed file for exactly this commit.
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
|
||||
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
|
||||
run: |
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
|
||||
|
||||
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
|
||||
- name: Package export
|
||||
@@ -136,7 +156,8 @@ jobs:
|
||||
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
# Cross-component deploy record, written last and only on a verified deploy --
|
||||
# same contract as versions/backend.json and versions/mcp.json.
|
||||
# same contract as versions/backend.json and versions/mcp.json. docsCommit says which
|
||||
# docs revision the live What's new panel was generated from (as for the MCP corpus).
|
||||
- name: Publish version manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -145,6 +166,7 @@ jobs:
|
||||
commit = '${{ github.sha }}'.Substring(0, 7)
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
|
||||
@@ -145,6 +145,10 @@ internal/docs/~$Introduction-AutopilotMonitor.pptx
|
||||
# changes each run) and served at /version.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/version.json
|
||||
|
||||
# What's new payload: generated by scripts/whats-new/build-whats-new.js from the docs
|
||||
# checkout on every build and served at /whats-new.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/whats-new.json
|
||||
|
||||
|
||||
# Local IME installer scratch (MSI + extracted payload per version, for grepping
|
||||
# in binaries). Operator-only, never committed.
|
||||
|
||||
+1
-1
Submodule internal updated: 2496a42aa8...41d3f15126
@@ -114,6 +114,11 @@
|
||||
"description": "Emit behavior: \"always\" (default — emit on every collection) or \"on_change\" (poll on the trigger cadence but emit only when the collected result changes; the first in-scope result always emits).",
|
||||
"enum": ["always", "on_change"]
|
||||
},
|
||||
"enrichErrorCodes": {
|
||||
"type": "boolean",
|
||||
"description": "Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, ...) with the Windows/MSI/Intune error-code catalog, attaching the same *Info siblings built-in events get. Default false because gather-rule codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be wrong. Enable only when the parsed source really uses Windows codes (e.g. an msiexec or IME log).",
|
||||
"default": false
|
||||
},
|
||||
"outputEventType": {
|
||||
"type": "string",
|
||||
"description": "EventType for the emitted event (e.g., \"gather_proxy_settings\")."
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="GatherRule.EnrichErrorCodes"/> is an opt-in the backend honours at response time:
|
||||
/// the agent stamps <c>enrichErrorCodes: true</c> into the event data of a rule that set it and
|
||||
/// nothing at all otherwise (the default payload stays as it was).
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorEnrichErrorCodesTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _eventsGate = new object();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000";
|
||||
|
||||
public GatherRuleExecutorEnrichErrorCodesTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant",
|
||||
evt => { lock (_eventsGate) _events.Add(evt); },
|
||||
logger)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private EnrollmentEvent SingleEvent()
|
||||
{
|
||||
lock (_eventsGate) return Assert.Single(_events);
|
||||
}
|
||||
|
||||
private bool WaitForEventCount(int count, int timeoutMs = 10000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
lock (_eventsGate) if (_events.Count >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
lock (_eventsGate) return _events.Count >= count;
|
||||
}
|
||||
|
||||
private static GatherRule StartupRule(bool enrichErrorCodes) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-EEC-001",
|
||||
Title = "enrich test",
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
EnrichErrorCodes = enrichErrorCodes,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void StampRuleMarkers_AddsMarkerOnlyWhenOptedIn()
|
||||
{
|
||||
var optedIn = new Dictionary<string, object> { ["exitCode"] = "1603" };
|
||||
GatherRuleExecutor.StampRuleMarkers(StartupRule(enrichErrorCodes: true), optedIn);
|
||||
Assert.Equal(true, optedIn[Constants.GatherRuleDataKeys.EnrichErrorCodes]);
|
||||
|
||||
var defaultRule = new Dictionary<string, object> { ["exitCode"] = "1603" };
|
||||
GatherRuleExecutor.StampRuleMarkers(StartupRule(enrichErrorCodes: false), defaultRule);
|
||||
Assert.False(defaultRule.ContainsKey(Constants.GatherRuleDataKeys.EnrichErrorCodes));
|
||||
Assert.Single(defaultRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteRule_StampsMarker_WhenRuleOptsIn()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { StartupRule(enrichErrorCodes: true) });
|
||||
|
||||
Assert.True(_executor.WaitForStartupRules(30));
|
||||
Assert.True(WaitForEventCount(1));
|
||||
var evt = SingleEvent();
|
||||
Assert.Equal(GatherRuleExecutor.SourceName, evt.Source);
|
||||
Assert.Equal(true, evt.Data[Constants.GatherRuleDataKeys.EnrichErrorCodes]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteRule_LeavesPayloadUnchanged_ByDefault()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { StartupRule(enrichErrorCodes: false) });
|
||||
|
||||
Assert.True(_executor.WaitForStartupRules(30));
|
||||
Assert.True(WaitForEventCount(1));
|
||||
Assert.False(SingleEvent().Data.ContainsKey(Constants.GatherRuleDataKeys.EnrichErrorCodes));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -278,6 +278,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
@@ -347,6 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
|
||||
+13
-1
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
/// emits (executor, context guard events, collectors). The timeline feed uses it to keep
|
||||
/// gather output from re-triggering on_event rules.
|
||||
/// </summary>
|
||||
public const string SourceName = "GatherRuleExecutor";
|
||||
public const string SourceName = Constants.EventSources.GatherRuleExecutor;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on on_event executions per rule and session. on_event has no natural
|
||||
@@ -496,6 +496,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
result["ruleId"] = rule.RuleId;
|
||||
result["ruleTitle"] = rule.Title;
|
||||
StampRuleMarkers(rule, result);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType : Constants.EventTypes.GatherResult;
|
||||
|
||||
@@ -537,6 +538,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// ===== Phase scope (ActivePhases / ActiveFromPhase) =====
|
||||
|
||||
/// <summary>
|
||||
/// Injects the per-rule opt-in markers the backend reads at response time. Called after
|
||||
/// the on_change hash is taken (the markers are rule config, not collected data) by the
|
||||
/// executor and by collectors that emit directly (logparser).
|
||||
/// </summary>
|
||||
internal static void StampRuleMarkers(GatherRule rule, Dictionary<string, object> data)
|
||||
{
|
||||
if (rule.EnrichErrorCodes)
|
||||
data[Constants.GatherRuleDataKeys.EnrichErrorCodes] = true;
|
||||
}
|
||||
|
||||
private static bool HasPhaseScope(GatherRule rule)
|
||||
{
|
||||
return (rule.ActivePhases != null && rule.ActivePhases.Count > 0)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the derivation shared by the agent channel (GET agent/config) and the Global-Admin
|
||||
/// report route (GET config/{tenantId}/effective-agent-config): every rule the Tenant Config
|
||||
/// Report used to re-implement in the web now has exactly one implementation, and these
|
||||
/// facts are its contract.
|
||||
/// </summary>
|
||||
public class AgentConfigResolverTests
|
||||
{
|
||||
private const string TenantId = "11111111-2222-3333-4444-555555555555";
|
||||
private static readonly DateTime Now = new(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TenantConfiguration Tenant(Action<TenantConfiguration>? mutate = null)
|
||||
{
|
||||
var c = new TenantConfiguration { TenantId = TenantId, DomainName = "contoso.example", UpdatedBy = "ga@operator.example" };
|
||||
mutate?.Invoke(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
private static AdminConfiguration Admin(Action<AdminConfiguration>? mutate = null)
|
||||
{
|
||||
var a = new AdminConfiguration();
|
||||
mutate?.Invoke(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
private static AgentConfigResponse Build(TenantConfiguration tenant, AdminConfiguration? admin = null, int agentMajor = AgentConfigResolver.CurrentAgentMajor)
|
||||
=> AgentConfigResolver.Build(tenant, admin ?? Admin(), new List<GatherRule>(), new List<ImeLogPattern>(), agentMajor, Now).Response;
|
||||
|
||||
[Fact]
|
||||
public void Fresh_tenant_gets_the_agent_defaults()
|
||||
{
|
||||
var r = Build(Tenant());
|
||||
|
||||
Assert.Equal(AgentConfigResolver.ConfigVersion, r.ConfigVersion);
|
||||
Assert.Equal(AutopilotMonitor.Shared.Constants.DefaultUploadIntervalSeconds, r.UploadIntervalSeconds);
|
||||
Assert.Equal(10, r.UploadIntervalSeconds); // the report showed 30 for a month — pinned
|
||||
Assert.True(r.SelfDestructOnComplete);
|
||||
Assert.False(r.KeepLogFile);
|
||||
Assert.True(r.EnableGeoLocation);
|
||||
Assert.Equal(5, r.MaxAuthFailures);
|
||||
Assert.Equal(0, r.AuthFailureTimeoutMinutes);
|
||||
Assert.Equal("Info", r.LogLevel);
|
||||
Assert.Equal(10, r.RebootDelaySeconds);
|
||||
Assert.Equal(60, r.EnrollmentSummaryTimeoutSeconds);
|
||||
Assert.Equal(120, r.EnrollmentSummaryLaunchRetrySeconds);
|
||||
Assert.Equal(100, r.MaxBatchSize);
|
||||
Assert.Equal("time.windows.com", r.NtpServer);
|
||||
Assert.Equal("Off", r.DiagnosticsUploadMode);
|
||||
Assert.False(r.DiagnosticsUploadEnabled);
|
||||
Assert.False(r.UnrestrictedMode);
|
||||
Assert.False(r.DeviceBlocked);
|
||||
Assert.False(r.DeviceKillSignal);
|
||||
Assert.Null(r.UnblockAt);
|
||||
Assert.Null(r.MigrateToApiBaseUrl);
|
||||
|
||||
Assert.Equal(360, r.Collectors.AgentMaxLifetimeMinutes);
|
||||
Assert.Equal(15, r.Collectors.CollectorIdleTimeoutMinutes);
|
||||
Assert.Equal(10, r.Collectors.DesktopDetectorNoCandidateTimeoutMinutes);
|
||||
Assert.Equal(new[] { 100, 1005, 1010 }, r.Collectors.ModernDeploymentHarmlessEventIds);
|
||||
|
||||
Assert.True(r.Analyzers.EnableLocalAdminAnalyzer);
|
||||
Assert.Empty(r.Analyzers.LocalAdminAllowedAccounts);
|
||||
Assert.False(r.Analyzers.EnableSoftwareInventoryAnalyzer);
|
||||
Assert.True(r.Analyzers.EnableIntegrityBypassAnalyzer);
|
||||
Assert.False(r.Analyzers.EnableRealmJoinWatcher);
|
||||
Assert.False(r.Analyzers.KeepAwakeDuringUserEsp);
|
||||
Assert.True(r.Analyzers.EnableConsoleBypassDetection);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "CustomerSas", "Off", false)]
|
||||
[InlineData(null, "Hosted", "Off", true)] // hosted destination needs no SAS
|
||||
[InlineData("https://acct.blob.core.windows.net/c?sv=1", "CustomerSas", "Off", true)] // SAS enables regardless of mode
|
||||
public void Diagnostics_upload_enabled_follows_sas_or_hosted_not_the_mode(string? sas, string destination, string mode, bool expected)
|
||||
{
|
||||
var r = Build(Tenant(t =>
|
||||
{
|
||||
t.DiagnosticsBlobSasUrl = sas!;
|
||||
t.DiagnosticsUploadDestination = destination;
|
||||
t.DiagnosticsUploadMode = mode;
|
||||
}));
|
||||
Assert.Equal(expected, r.DiagnosticsUploadEnabled);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("free", true, true, false)] // Community: entitlement missing
|
||||
[InlineData("pro", false, true, false)] // GA gate off
|
||||
[InlineData("pro", true, false, false)] // tenant toggle off
|
||||
[InlineData("pro", true, true, true)]
|
||||
public void Unrestricted_mode_needs_entitlement_and_gate_and_toggle(string planTier, bool gaGate, bool toggle, bool expected)
|
||||
{
|
||||
var r = Build(Tenant(t =>
|
||||
{
|
||||
t.PlanTier = planTier;
|
||||
t.UnrestrictedModeEnabled = gaGate;
|
||||
t.UnrestrictedMode = toggle;
|
||||
}));
|
||||
Assert.Equal(expected, r.UnrestrictedMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operator_knobs_come_from_the_admin_configuration()
|
||||
{
|
||||
var admin = Admin(a =>
|
||||
{
|
||||
a.CollectorIdleTimeoutMinutes = 25;
|
||||
a.DesktopDetectorNoCandidateTimeoutMinutes = 7;
|
||||
a.AllowAgentDowngrade = true;
|
||||
a.ModernDeploymentHarmlessEventIdsJson = "[100, 42]";
|
||||
a.LatestAgentV2Sha256 = "zip-v2";
|
||||
a.LatestAgentV2ExeSha256 = "exe-v2";
|
||||
});
|
||||
|
||||
var r = Build(Tenant(), admin);
|
||||
|
||||
Assert.Equal(25, r.Collectors.CollectorIdleTimeoutMinutes);
|
||||
Assert.Equal(7, r.Collectors.DesktopDetectorNoCandidateTimeoutMinutes);
|
||||
Assert.True(r.AllowAgentDowngrade);
|
||||
Assert.Equal(new[] { 100, 42 }, r.Collectors.ModernDeploymentHarmlessEventIds);
|
||||
Assert.Equal("zip-v2", r.LatestAgentSha256);
|
||||
Assert.Equal("exe-v2", r.LatestAgentExeSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_major_gets_empty_hashes_the_current_major_gets_v2()
|
||||
{
|
||||
var admin = Admin(a => { a.LatestAgentV2Sha256 = "zip-v2"; a.LatestAgentV2ExeSha256 = "exe-v2"; });
|
||||
|
||||
var legacy = Build(Tenant(), admin, agentMajor: 1);
|
||||
Assert.Equal(string.Empty, legacy.LatestAgentSha256);
|
||||
|
||||
var current = Build(Tenant(), admin, agentMajor: AgentConfigResolver.CurrentAgentMajor);
|
||||
Assert.Equal("zip-v2", current.LatestAgentSha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_overrides_win_over_defaults()
|
||||
{
|
||||
var r = Build(Tenant(t =>
|
||||
{
|
||||
t.MaxBatchSize = 25;
|
||||
t.LogLevel = "Debug";
|
||||
t.NtpServer = "";
|
||||
t.LocalAdminAllowedAccountsJson = "[\"Admin\",\"Helpdesk\"]";
|
||||
t.AgentMaxLifetimeMinutes = 120;
|
||||
t.EnableConsoleBypassDetection = false;
|
||||
}));
|
||||
|
||||
Assert.Equal(25, r.MaxBatchSize);
|
||||
Assert.Equal("Debug", r.LogLevel);
|
||||
Assert.Equal("time.windows.com", r.NtpServer); // empty string falls back like null
|
||||
Assert.Equal(new[] { "Admin", "Helpdesk" }, r.Analyzers.LocalAdminAllowedAccounts);
|
||||
Assert.Equal(120, r.Collectors.AgentMaxLifetimeMinutes);
|
||||
Assert.False(r.Analyzers.EnableConsoleBypassDetection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Migration_target_is_served_when_valid_and_reported_when_rejected()
|
||||
{
|
||||
const string Us = "https://autopilotmonitor-api-us.azurewebsites.net";
|
||||
var served = AgentConfigResolver.Build(Tenant(), Admin(a => a.AgentMigrateApiBaseUrl = Us),
|
||||
new List<GatherRule>(), new List<ImeLogPattern>(), AgentConfigResolver.CurrentAgentMajor, Now);
|
||||
Assert.Equal(Us, served.Response.MigrateToApiBaseUrl);
|
||||
Assert.Null(served.RejectedMigrateCandidate);
|
||||
|
||||
var rejected = AgentConfigResolver.Build(Tenant(), Admin(a => a.AgentMigrateApiBaseUrl = "https://evil.example"),
|
||||
new List<GatherRule>(), new List<ImeLogPattern>(), AgentConfigResolver.CurrentAgentMajor, Now);
|
||||
Assert.Null(rejected.Response.MigrateToApiBaseUrl);
|
||||
Assert.Equal("https://evil.example", rejected.RejectedMigrateCandidate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Catalogs_pass_through_unchanged()
|
||||
{
|
||||
var rules = new List<GatherRule> { new() { RuleId = "GATHER-TEST-001" } };
|
||||
var patterns = new List<ImeLogPattern> { new() { PatternId = "IME-TEST-001" } };
|
||||
var r = AgentConfigResolver.Build(Tenant(), Admin(), rules, patterns, AgentConfigResolver.CurrentAgentMajor, Now).Response;
|
||||
Assert.Same(rules, r.GatherRules);
|
||||
Assert.Same(patterns, r.ImeLogPatterns);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
using Xunit;
|
||||
@@ -8,7 +8,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Pins the endpoint-migration serving contract: the shared allowlist rules
|
||||
/// (<see cref="AgentEndpointMigrationRules"/>, used verbatim by the agent too) and the
|
||||
/// backend-side per-tenant resolution (<see cref="GetAgentConfigFunction.ResolveMigrateTarget"/> —
|
||||
/// backend-side per-tenant resolution (<see cref="AgentConfigResolver.ResolveMigrateTarget"/> —
|
||||
/// tenant override wins, empty override pins, invalid values are never served).
|
||||
/// </summary>
|
||||
public class AgentEndpointMigrationTests
|
||||
@@ -75,7 +75,7 @@ public class AgentEndpointMigrationTests
|
||||
{
|
||||
var config = new AdminConfiguration();
|
||||
|
||||
Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(rejected);
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ public class AgentEndpointMigrationTests
|
||||
{
|
||||
var config = new AdminConfiguration { AgentMigrateApiBaseUrl = UsUrl };
|
||||
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantB, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,8 +97,8 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"{EuUrl}\"}}"
|
||||
};
|
||||
|
||||
Assert.Equal(EuUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
|
||||
Assert.Equal(EuUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantB, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -110,9 +110,9 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"\"}}"
|
||||
};
|
||||
|
||||
Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(rejected); // pinned, not rejected
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantB, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,7 +123,7 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateTenantOverridesJson = $"{{\"{TenantA.ToUpperInvariant()}\": \"{UsUrl}\"}}"
|
||||
};
|
||||
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -131,7 +131,7 @@ public class AgentEndpointMigrationTests
|
||||
{
|
||||
var config = new AdminConfiguration { AgentMigrateApiBaseUrl = "https://attacker.example.com" };
|
||||
|
||||
Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Null(AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out var rejected));
|
||||
Assert.Equal("https://attacker.example.com", rejected);
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateTenantOverridesJson = "{not json"
|
||||
};
|
||||
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -155,6 +155,6 @@ public class AgentEndpointMigrationTests
|
||||
AgentMigrateApiBaseUrl = "https://AutopilotMonitor-API-US.azurewebsites.net/"
|
||||
};
|
||||
|
||||
Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
|
||||
Assert.Equal(UsUrl, AgentConfigResolver.ResolveMigrateTarget(config, TenantA, out _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,76 @@ public class AuthFunctionTests
|
||||
Assert.True((bool)body.hasMcpAccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_SurfacesWhatsNewSeenMarks()
|
||||
{
|
||||
var platformSeen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var agentSeen = new DateTime(2026, 9, 8, 10, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId,
|
||||
whatsNewSeenPlatformUtc: platformSeen,
|
||||
whatsNewSeenAgentUtc: agentSeen);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal(platformSeen, (DateTime)body.whatsNewSeenPlatformUtc);
|
||||
Assert.Equal(agentSeen, (DateTime)body.whatsNewSeenAgentUtc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("platform", "platform")]
|
||||
[InlineData("Platform", "platform")]
|
||||
[InlineData("agent", "agent")]
|
||||
public void TryNormalizeWhatsNewSeen_AcceptsKnownChannels(string input, string expected)
|
||||
{
|
||||
var seen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var ok = AuthFunction.TryNormalizeWhatsNewSeen(
|
||||
new WhatsNewSeenRequest { Channel = input, SeenUtc = seen },
|
||||
seen.AddMinutes(1),
|
||||
out var channel,
|
||||
out var seenUtc,
|
||||
out var error);
|
||||
|
||||
Assert.True(ok, error);
|
||||
Assert.Equal(expected, channel);
|
||||
Assert.Equal(seen, seenUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeWhatsNewSeen_RejectsUnknownChannel()
|
||||
{
|
||||
var ok = AuthFunction.TryNormalizeWhatsNewSeen(
|
||||
new WhatsNewSeenRequest { Channel = "other", SeenUtc = DateTime.UtcNow },
|
||||
DateTime.UtcNow,
|
||||
out _,
|
||||
out _,
|
||||
out var error);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal("channel must be 'platform' or 'agent'", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeWhatsNewSeen_ClampsFarFutureTimestamp()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var ok = AuthFunction.TryNormalizeWhatsNewSeen(
|
||||
new WhatsNewSeenRequest { Channel = "agent", SeenUtc = now.AddMinutes(6) },
|
||||
now,
|
||||
out _,
|
||||
out var seenUtc,
|
||||
out var error);
|
||||
|
||||
Assert.True(ok, error);
|
||||
Assert.Equal(now, seenUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_OperatorUser_ReturnsCorrectRole()
|
||||
{
|
||||
|
||||
@@ -182,6 +182,7 @@ public class CrossTenantAccessTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("PATCH", "/api/config/{0}/plan")]
|
||||
[InlineData("GET", "/api/config/{0}/effective-agent-config")]
|
||||
[InlineData("POST", "/api/preview/whitelist/{0}")]
|
||||
[InlineData("DELETE", "/api/preview/whitelist/{0}")]
|
||||
[InlineData("POST", "/api/preview/send-welcome-email/{0}")]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class EndpointNormalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Normalize_StripsApiPrefixReplacesGuidsAndKeyChars()
|
||||
{
|
||||
var normalized = EndpointNormalizer.Normalize("/api/Sessions/0f8fad5b-d9cb-469f-a165-70867728950e/events?x=1");
|
||||
Assert.Equal("sessions_{id}_events_x=1", normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_RealToolName_PassesThroughUnchanged()
|
||||
{
|
||||
// Existing UserUsageLog rows are keyed by "<tool>:<endpoint>"; a real tool name must keep its key.
|
||||
Assert.Equal("get_session_events", EndpointNormalizer.ToolNameKey("get_session_events"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_NullOrEmpty_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal(string.Empty, EndpointNormalizer.ToolNameKey(null));
|
||||
Assert.Equal(string.Empty, EndpointNormalizer.ToolNameKey(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_WithCrlfAndControlChars_StripsThem()
|
||||
{
|
||||
// The log-forging vector: a header that tries to inject a fake log line through the row key.
|
||||
var forged = $"get_metrics\r\n[McpQuota] FAKE{(char)0}{(char)27}{(char)0x2028}x";
|
||||
var key = EndpointNormalizer.ToolNameKey(forged);
|
||||
Assert.Equal("get_metrics[McpQuota] FAKEx", key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_WithTableKeyIllegalChars_ReplacesThem()
|
||||
{
|
||||
// '/', '\', '#', '?' are rejected by Table Storage in keys — a header carrying them would make
|
||||
// every usage increment fail with 400, so they are replaced the same way the path is.
|
||||
Assert.Equal("a_b_c_d_e", EndpointNormalizer.ToolNameKey("a/b\\c#d?e"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_LongerThanCap_IsTruncated()
|
||||
{
|
||||
var key = EndpointNormalizer.ToolNameKey(new string('t', EndpointNormalizer.MaxToolNameLength + 10));
|
||||
Assert.Equal(EndpointNormalizer.MaxToolNameLength, key.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolNameKey_OnlyControlChars_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal(string.Empty, EndpointNormalizer.ToolNameKey("\r\n "));
|
||||
}
|
||||
}
|
||||
@@ -147,11 +147,11 @@ public class FleetHealthPayloadTests
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
|
||||
|
||||
Assert.Equal(6, payload.ModelHealth.Count);
|
||||
Assert.Equal("Contoso Model7", payload.ModelHealth[0].Model);
|
||||
Assert.Equal("Contoso Model7", payload.ModelHealth[0].Label);
|
||||
Assert.Equal(7, payload.ModelHealth[0].Total);
|
||||
Assert.Equal(7, payload.ModelHealth[0].Succeeded);
|
||||
// The smallest (volume 1) is dropped by the top-6 cut.
|
||||
Assert.DoesNotContain(payload.ModelHealth, m => m.Model == "Contoso Model1");
|
||||
Assert.DoesNotContain(payload.ModelHealth, m => m.Label == "Contoso Model1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -182,7 +182,47 @@ public class FleetHealthPayloadTests
|
||||
new List<SessionSummary> { S(SessionStatus.Succeeded, manufacturer: "", model: "") },
|
||||
days: 7);
|
||||
|
||||
Assert.Equal("Unknown", Assert.Single(payload.ModelHealth).Model);
|
||||
var m = Assert.Single(payload.ModelHealth);
|
||||
Assert.Equal("Unknown", m.Label);
|
||||
Assert.Equal("", m.Manufacturer);
|
||||
Assert.Equal("", m.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModelBuckets_CarryManufacturerAndModelSeparately_OnAllThreeLists()
|
||||
{
|
||||
// The dashboard deep link addresses the two session fields as two qualified search
|
||||
// terms; a client must never have to split the label again.
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Succeeded, durationSeconds: 600, manufacturer: " Contoso ", model: "EliteBook 840"),
|
||||
S(SessionStatus.Failed, manufacturer: "Contoso", model: "EliteBook 840"),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
|
||||
|
||||
var health = Assert.Single(payload.ModelHealth);
|
||||
Assert.Equal(("Contoso", "EliteBook 840", "Contoso EliteBook 840"), (health.Manufacturer, health.Model, health.Label));
|
||||
Assert.Equal(2, health.Total);
|
||||
var slow = Assert.Single(payload.SlowestModels);
|
||||
Assert.Equal(("Contoso", "EliteBook 840", "Contoso EliteBook 840"), (slow.Manufacturer, slow.Model, slow.Label));
|
||||
var failing = Assert.Single(payload.TopFailingModels);
|
||||
Assert.Equal(("Contoso", "EliteBook 840", "Contoso EliteBook 840"), (failing.Manufacturer, failing.Model, failing.Label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModelBuckets_GroupOnThePair_NotOnTheLabel()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Succeeded, manufacturer: "Contoso", model: "A B"),
|
||||
S(SessionStatus.Succeeded, manufacturer: "Contoso A", model: "B"),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
|
||||
|
||||
Assert.Equal(2, payload.ModelHealth.Count);
|
||||
Assert.All(payload.ModelHealth, m => Assert.Equal("Contoso A B", m.Label));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -199,10 +239,10 @@ public class FleetHealthPayloadTests
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
|
||||
|
||||
Assert.Equal(2, payload.SlowestModels.Count);
|
||||
Assert.Equal("Contoso Slow", payload.SlowestModels[0].Model);
|
||||
Assert.Equal("Contoso Slow", payload.SlowestModels[0].Label);
|
||||
Assert.Equal(30, payload.SlowestModels[0].AvgMinutes);
|
||||
Assert.Equal(1, payload.SlowestModels[0].Count);
|
||||
Assert.Equal("Contoso Fast", payload.SlowestModels[1].Model);
|
||||
Assert.Equal("Contoso Fast", payload.SlowestModels[1].Label);
|
||||
Assert.Equal(10, payload.SlowestModels[1].AvgMinutes);
|
||||
}
|
||||
|
||||
@@ -227,13 +267,13 @@ public class FleetHealthPayloadTests
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 30);
|
||||
|
||||
Assert.Equal(2, payload.TopFailingModels.Count);
|
||||
Assert.Equal("Contoso B", payload.TopFailingModels[0].Model);
|
||||
Assert.Equal("Contoso B", payload.TopFailingModels[0].Label);
|
||||
Assert.Equal(2, payload.TopFailingModels[0].Failed);
|
||||
Assert.Equal(100, payload.TopFailingModels[0].FailureRate);
|
||||
Assert.Equal("Contoso A", payload.TopFailingModels[1].Model);
|
||||
Assert.Equal("Contoso A", payload.TopFailingModels[1].Label);
|
||||
Assert.Equal(4, payload.TopFailingModels[1].Total);
|
||||
Assert.Equal(50, payload.TopFailingModels[1].FailureRate);
|
||||
Assert.DoesNotContain(payload.TopFailingModels, m => m.Model == "Contoso C");
|
||||
Assert.DoesNotContain(payload.TopFailingModels, m => m.Label == "Contoso C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -129,6 +129,29 @@ public class GatherRuleScopeFieldsTests
|
||||
Assert.Null(mapped.ActivePhases);
|
||||
Assert.Null(mapped.ActiveFromPhase);
|
||||
Assert.Null(mapped.EmitMode);
|
||||
Assert.False(mapped.EnrichErrorCodes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Roundtrip_EnrichErrorCodes_SurvivesStoreAndMap()
|
||||
{
|
||||
var (service, lastUpserted) = BuildStorageHarness();
|
||||
var rule = new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-ENRICH-001",
|
||||
Title = "MSI log parser",
|
||||
CollectorType = "logparser",
|
||||
Target = "C:\\Windows\\Temp\\msi.log",
|
||||
Trigger = "interval",
|
||||
IntervalSeconds = 60,
|
||||
OutputEventType = "gather_msi",
|
||||
EnrichErrorCodes = true,
|
||||
};
|
||||
|
||||
Assert.True(await service.StoreGatherRuleAsync(rule, "global"));
|
||||
var mapped = service.MapToGatherRule(lastUpserted()!);
|
||||
|
||||
Assert.True(mapped.EnrichErrorCodes);
|
||||
}
|
||||
|
||||
// ── ContentEquivalent ──────────────────────────────────────────────────
|
||||
@@ -175,6 +198,10 @@ public class GatherRuleScopeFieldsTests
|
||||
var emit = BaseRule();
|
||||
emit.EmitMode = "on_change";
|
||||
Assert.False(GatherRuleService.ContentEquivalent(baseline, emit));
|
||||
|
||||
var enrich = BaseRule();
|
||||
enrich.EnrichErrorCodes = true;
|
||||
Assert.False(GatherRuleService.ContentEquivalent(baseline, enrich));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -37,6 +37,7 @@ public class GatherRuleUpdatePartialMergeTests
|
||||
CreatedAt = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc),
|
||||
ActiveFromPhase = "AccountSetup",
|
||||
EmitMode = "on_change",
|
||||
EnrichErrorCodes = true,
|
||||
};
|
||||
|
||||
private static (GatherRuleService service, Mock<IRuleRepository> repo, List<GatherRule> stored) BuildService(
|
||||
@@ -77,6 +78,7 @@ public class GatherRuleUpdatePartialMergeTests
|
||||
Assert.Equal("Version", written.Parameters["valueName"]);
|
||||
Assert.Equal("AccountSetup", written.ActiveFromPhase);
|
||||
Assert.Equal("on_change", written.EmitMode);
|
||||
Assert.True(written.EnrichErrorCodes);
|
||||
Assert.Equal(existing.CreatedAt, written.CreatedAt);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="GetAgentConfigFunction.ParseAgentMajor"/> — the pure decision logic that picks
|
||||
/// Tests for <see cref="AgentConfigResolver.ParseAgentMajor"/> — the pure decision logic that picks
|
||||
/// the per-line hash-oracle field set based on the X-Agent-Version header value.
|
||||
/// </summary>
|
||||
public class GetAgentConfigFunctionTests
|
||||
@@ -29,7 +29,7 @@ public class GetAgentConfigFunctionTests
|
||||
[InlineData("vNEXT", 1)]
|
||||
public void ParseAgentMajor_ExtractsMajor(string? agentVersion, int expectedMajor)
|
||||
{
|
||||
var actual = GetAgentConfigFunction.ParseAgentMajor(agentVersion);
|
||||
var actual = AgentConfigResolver.ParseAgentMajor(agentVersion);
|
||||
Assert.Equal(expectedMajor, actual);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public class GetAgentConfigFunctionTests
|
||||
P(@"C:\Windows\SetupDiag\*.log", "tenant"),
|
||||
};
|
||||
|
||||
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(global, tenant);
|
||||
var merged = AgentConfigResolver.MergeDiagnosticsLogPaths(global, tenant);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
@@ -63,7 +63,7 @@ public class GetAgentConfigFunctionTests
|
||||
[Fact]
|
||||
public void MergeDiagnosticsLogPaths_DropsBlankEntries()
|
||||
{
|
||||
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(
|
||||
var merged = AgentConfigResolver.MergeDiagnosticsLogPaths(
|
||||
new[] { P(""), P(" "), P(@"C:\Install\Log\*.log") },
|
||||
new DiagnosticsLogPath[] { null!, P(@"C:\Install\Log\*.log") });
|
||||
|
||||
@@ -90,7 +90,7 @@ public class GetAgentConfigFunctionTests
|
||||
public void ResolveDiagnosticsUploadEnabled_GatesOnSasOrHosted(
|
||||
string? diagnosticsBlobSasUrl, string? destination, bool expected)
|
||||
{
|
||||
var actual = GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(diagnosticsBlobSasUrl, destination);
|
||||
var actual = AgentConfigResolver.ResolveDiagnosticsUploadEnabled(diagnosticsBlobSasUrl, destination);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,17 +77,62 @@ public class HealthCheckServiceMcpServerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TimeoutOrConnectionFailure_ReportsWarningForColdStart()
|
||||
public async Task Check_Timeout_ReportsWarmingSoTheCallerCanRePoll()
|
||||
{
|
||||
var svc = BuildServiceWithFactory(
|
||||
new ThrowingHttpClientFactory(new TaskCanceledException("timeout")));
|
||||
|
||||
var check = await svc.CheckMcpServerAsync();
|
||||
|
||||
// Failure to wake the scaled-to-zero container within the budget is a warning,
|
||||
// not a hard outage (a re-check may still succeed once it finishes warming).
|
||||
// A scaled-to-zero container that did not answer inside the budget is "warming",
|
||||
// NOT a fault: the caller re-checks and the activation we triggered keeps running.
|
||||
Assert.Equal("warming", check.Status);
|
||||
// The default budget is pinned here because it is set nowhere else in the repo —
|
||||
// the code default IS the live production value.
|
||||
Assert.Contains("3s", check.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_Timeout_HonoursConfiguredBudget()
|
||||
{
|
||||
var svc = BuildServiceWithFactory(
|
||||
new ThrowingHttpClientFactory(new TaskCanceledException("timeout")),
|
||||
extraConfig: new Dictionary<string, string?> { ["McpServerHealthTimeoutSeconds"] = "7" });
|
||||
|
||||
var check = await svc.CheckMcpServerAsync();
|
||||
|
||||
Assert.Equal("warming", check.Status);
|
||||
Assert.Contains("7s", check.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_ConnectionFailure_ReportsWarningNotWarmingAndKeepsTheHostOut()
|
||||
{
|
||||
var svc = BuildServiceWithFactory(
|
||||
new ThrowingHttpClientFactory(new System.Net.Http.HttpRequestException("no such host mcp.example.test")));
|
||||
|
||||
var check = await svc.CheckMcpServerAsync();
|
||||
|
||||
// Reaching the network and failing to connect is a real warning — distinct from
|
||||
// the expected cold-start path, so it must not be softened to "warming".
|
||||
Assert.Equal("warning", check.Status);
|
||||
Assert.Contains("could not be reached", check.Message);
|
||||
Assert.DoesNotContain("http", check.Message, System.StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("mcp.example.test", check.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(12, false)]
|
||||
[InlineData(750, false)]
|
||||
[InlineData(751, true)]
|
||||
[InlineData(2400, true)]
|
||||
public void McpReachableMessage_marksOnlyAnswersAboveTheWarmThresholdAsColdStart(long elapsedMs, bool expectColdStart)
|
||||
{
|
||||
var message = HealthCheckService.McpReachableMessage(elapsedMs);
|
||||
|
||||
// Both arms keep the word "reachable" — the healthy-path assertions rely on it.
|
||||
Assert.Contains("reachable", message);
|
||||
Assert.Contains($"{elapsedMs}ms", message);
|
||||
Assert.Equal(expectColdStart, message.Contains("cold start"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -101,13 +146,21 @@ public class HealthCheckServiceMcpServerTests
|
||||
return BuildServiceWithFactory(new StubHttpClientFactory(handler));
|
||||
}
|
||||
|
||||
private static HealthCheckService BuildServiceWithFactory(System.Net.Http.IHttpClientFactory factory)
|
||||
private static HealthCheckService BuildServiceWithFactory(
|
||||
System.Net.Http.IHttpClientFactory factory,
|
||||
Dictionary<string, string?>? extraConfig = null)
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["McpServerUrl"] = "https://mcp.example.test",
|
||||
};
|
||||
if (extraConfig != null)
|
||||
{
|
||||
foreach (var kv in extraConfig) settings[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["McpServerUrl"] = "https://mcp.example.test",
|
||||
})
|
||||
.AddInMemoryCollection(settings)
|
||||
.Build();
|
||||
|
||||
return new HealthCheckService(
|
||||
|
||||
@@ -28,6 +28,8 @@ public class InfrastructureWireParityTests
|
||||
var displayName = "Admin User";
|
||||
var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111";
|
||||
var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" };
|
||||
var platformSeen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var agentSeen = new DateTime(2026, 9, 8, 10, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
@@ -46,7 +48,9 @@ public class InfrastructureWireParityTests
|
||||
hasMcpAccess = true,
|
||||
homedApp = "primary",
|
||||
bootstrapTokenEnabled = true,
|
||||
unrestrictedModeEnabled = false
|
||||
unrestrictedModeEnabled = false,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -64,7 +68,9 @@ public class InfrastructureWireParityTests
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "primary",
|
||||
BootstrapTokenEnabled = true,
|
||||
UnrestrictedModeEnabled = false
|
||||
UnrestrictedModeEnabled = false,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,6 +84,8 @@ public class InfrastructureWireParityTests
|
||||
var displayName = "Reader User";
|
||||
var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222";
|
||||
string? role = null;
|
||||
DateTime? whatsNewSeenPlatformUtc = null;
|
||||
DateTime? whatsNewSeenAgentUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
@@ -96,7 +104,9 @@ public class InfrastructureWireParityTests
|
||||
hasMcpAccess = true,
|
||||
homedApp = "legacy",
|
||||
bootstrapTokenEnabled = false,
|
||||
unrestrictedModeEnabled = false
|
||||
unrestrictedModeEnabled = false,
|
||||
whatsNewSeenPlatformUtc,
|
||||
whatsNewSeenAgentUtc
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -114,7 +124,9 @@ public class InfrastructureWireParityTests
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "legacy",
|
||||
BootstrapTokenEnabled = false,
|
||||
UnrestrictedModeEnabled = false
|
||||
UnrestrictedModeEnabled = false,
|
||||
WhatsNewSeenPlatformUtc = null,
|
||||
WhatsNewSeenAgentUtc = null
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// is a new private parser copy — the state the 2026-09-04 API audit found six times over for
|
||||
/// <c>days</c> alone, each with its own default, cap and out-of-range rule. The baseline lists
|
||||
/// the sites that parse something other than a query parameter (a header, a stored cell, a
|
||||
/// version string); it only shrinks.
|
||||
/// version string); it only shrinks. The X-Agent-Version major parse moved to
|
||||
/// <c>Services/AgentConfigResolver.cs</c> (outside the scanned roots) with D-216.
|
||||
/// </summary>
|
||||
public class QueryParamGuardTests
|
||||
{
|
||||
@@ -19,7 +20,6 @@ public class QueryParamGuardTests
|
||||
/// <summary>Non-query parses, reviewed: file → why it is allowed.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Config/GetAgentConfigFunction.cs"] = "major of the X-Agent-Version header",
|
||||
["Functions/Ingest/IngestTelemetryFunction.cs"] = "X-Send-Time-Utc header (RoundtripKind, deliberately not a query instant)",
|
||||
["Functions/Raw/QueryRawEventsFunction.cs"] = "Severity / Sequence cells of raw table rows",
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "se= expiry inside the tenant's stored SAS URL",
|
||||
|
||||
@@ -77,7 +77,7 @@ public class RevokeEnforcementTests
|
||||
}
|
||||
|
||||
private static (TenantGroupManagementFunction Fn, Mock<IAdminRepository> Repo,
|
||||
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildGroupFn()
|
||||
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildGroupFn(ProConferralService? proConferral = null)
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
// Loose-mock defaults return a null Task — every repo read the flows touch needs a concrete setup.
|
||||
@@ -90,10 +90,17 @@ public class RevokeEnforcementTests
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var fn = new TenantGroupManagementFunction(
|
||||
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo),
|
||||
TestProConferral.Inert());
|
||||
proConferral ?? TestProConferral.Inert());
|
||||
return (fn, repo, audit, signalR);
|
||||
}
|
||||
|
||||
/// <summary>A recording conferral double (CallBase off, touches no storage): the group flows only need to prove WHAT they hand it.</summary>
|
||||
private static Mock<ProConferralService> RecordingProConferral() =>
|
||||
new(Mock.Of<IAdminRepository>(),
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions())),
|
||||
ManagedTenantProIndex.None,
|
||||
NullLogger<ProConferralService>.Instance) { CallBase = false };
|
||||
|
||||
// --- ChargeHomeTenantQuota flip: audited under every tenant, no-op on an unchanged value ---
|
||||
|
||||
[Fact]
|
||||
@@ -362,4 +369,53 @@ public class RevokeEnforcementTests
|
||||
TenantA, "DELETE", "DelegatedGroupAccess", "*", "ga@vendor.example",
|
||||
It.IsAny<Dictionary<string, string>?>()), Times.Once);
|
||||
}
|
||||
|
||||
// --- Group delete ends conferred Pro: every member of an OWNED group gets its grace anchor ---
|
||||
|
||||
private const string OwnerTenant = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteGroupCore_OwnedGroup_RecordsLossForEveryMember_EvenWithoutAssignees()
|
||||
{
|
||||
var proConferral = RecordingProConferral();
|
||||
var (fn, repo, audit, _) = BuildGroupFn(proConferral.Object);
|
||||
var ownedGroupId = Constants.TenantGroupIds.ForHomeTenant(OwnerTenant);
|
||||
repo.Setup(r => r.GetTenantGroupAsync(ownedGroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = ownedGroupId,
|
||||
Name = "Customers of owner",
|
||||
OwnerTenantId = OwnerTenant,
|
||||
TenantIds = new List<string> { TenantA, TenantB, OwnerTenant },
|
||||
AssigneeCount = 0,
|
||||
});
|
||||
repo.Setup(r => r.DeleteTenantGroupAsync(ownedGroupId)).ReturnsAsync(true);
|
||||
|
||||
await fn.DeleteGroupCoreAsync(ownedGroupId, "ga@vendor.example");
|
||||
|
||||
// The anchor does not depend on assignees (no assignee ⇒ no access audit, but Pro still ends);
|
||||
// the owner itself is never a managed tenant of its own group.
|
||||
proConferral.Verify(p => p.RecordLossAsync(TenantA, OwnerTenant, "group-deleted"), Times.Once);
|
||||
proConferral.Verify(p => p.RecordLossAsync(TenantB, OwnerTenant, "group-deleted"), Times.Once);
|
||||
proConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(2));
|
||||
VerifyNoAudit(audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteGroupCore_OperatorGroup_RecordsNoLoss()
|
||||
{
|
||||
var proConferral = RecordingProConferral();
|
||||
var (fn, repo, _, _) = BuildGroupFn(proConferral.Object);
|
||||
repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = GroupId,
|
||||
Name = "MSP Customers",
|
||||
TenantIds = new List<string> { TenantA },
|
||||
AssigneeCount = 0,
|
||||
});
|
||||
repo.Setup(r => r.DeleteTenantGroupAsync(GroupId)).ReturnsAsync(true);
|
||||
|
||||
await fn.DeleteGroupCoreAsync(GroupId, "ga@vendor.example");
|
||||
|
||||
proConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,88 @@ public sealed class ErrorCodeEnricherTests
|
||||
Assert.Single(nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_skips_gather_rule_events_by_default()
|
||||
{
|
||||
// A logparser gather rule on HP Image Assistant.log captured `exitcode` — HPiA has its own
|
||||
// exit-code table, so the MSI/Win32 catalog meaning ("ERROR_SUCCESS") must not be attached
|
||||
// unless the rule opted in (no enrichErrorCodes marker here).
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = "HPiA-UpdateStatus",
|
||||
Source = "GatherRuleExecutor",
|
||||
Message = "Gather: HP Updater Log Analyze",
|
||||
Data = new()
|
||||
{
|
||||
{ "exitcode", "0" },
|
||||
{ "errorCode", "0x80070005" },
|
||||
{ "enforcementState", "1000" },
|
||||
{ "ruleId", "hpia-log-collect" },
|
||||
},
|
||||
};
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.DoesNotContain(evt.Data.Keys, k => k.EndsWith("Info"));
|
||||
Assert.Equal(4, evt.Data.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_matches_gather_rule_source_case_insensitively()
|
||||
{
|
||||
var evt = EventWithData(new() { { "exitCode", "1603" } });
|
||||
evt.Source = "gatherruleexecutor";
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.False(evt.Data.ContainsKey("exitCodeInfo"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)] // in-process CLR bool
|
||||
[InlineData("true")] // string form after DataJson roundtrip
|
||||
[InlineData("True")]
|
||||
public void EnrichEvent_enriches_gather_rule_events_that_opted_in(object marker)
|
||||
{
|
||||
// A rule that parses an msiexec log set enrichErrorCodes; the agent stamped the marker.
|
||||
var evt = EventWithData(new()
|
||||
{
|
||||
{ "exitCode", "1603" },
|
||||
{ "ruleId", "msi-log" },
|
||||
{ "enrichErrorCodes", marker },
|
||||
});
|
||||
evt.Source = "GatherRuleExecutor";
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.Equal("ERROR_INSTALL_FAILURE", Info(evt, "exitCodeInfo")["symbol"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData("false")]
|
||||
[InlineData("")]
|
||||
public void EnrichEvent_ignores_gather_rule_marker_that_is_not_true(object marker)
|
||||
{
|
||||
var evt = EventWithData(new() { { "exitCode", "1603" }, { "enrichErrorCodes", marker } });
|
||||
evt.Source = "GatherRuleExecutor";
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.False(evt.Data.ContainsKey("exitCodeInfo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_marker_on_non_gather_event_changes_nothing()
|
||||
{
|
||||
// Built-in events are always enriched; the marker is only consulted for gather events.
|
||||
var evt = EventWithData(new() { { "exitCode", "1603" }, { "enrichErrorCodes", false } });
|
||||
|
||||
ErrorCodeEnricher.EnrichEvent(evt);
|
||||
|
||||
Assert.True(evt.Data.ContainsKey("exitCodeInfo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrichEvent_handles_null_data_dictionary()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -7,13 +12,16 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fragilitätsaudit P6.2: server-side free-text search (<c>q=</c>) for the dashboard search
|
||||
/// box, replacing the client's unbounded loadAll() sweep. The predicate must mirror the web
|
||||
/// dashboard's client-side searchable field set (minus derived-only tokens) — see the doc on
|
||||
/// <c>TableStorageService.MatchesFreeText</c>.
|
||||
/// Server-side free-text search (<c>q=</c>) for the dashboard search box. The predicate is the
|
||||
/// search grammar of <see cref="SessionSearchQuery"/> over the web dashboard's client-side
|
||||
/// searchable field set (minus derived-only tokens). Parity with the web parser is pinned by
|
||||
/// the shared case file <c>src/Web/autopilot-monitor-web/utils/session-search-syntax.cases.json</c>,
|
||||
/// which vitest runs against <c>app/dashboard/utils/sessionSearchQuery.ts</c>.
|
||||
/// </summary>
|
||||
public class SessionFreeTextSearchTests
|
||||
{
|
||||
private const string CasesRepoPath = "src/Web/autopilot-monitor-web/utils/session-search-syntax.cases.json";
|
||||
|
||||
private static SessionSummary Sample() => new()
|
||||
{
|
||||
SessionId = "22222222-2222-2222-2222-222222222222",
|
||||
@@ -66,6 +74,79 @@ public class SessionFreeTextSearchTests
|
||||
Assert.True(TableStorageService.MatchesFreeText(Sample(), string.Empty));
|
||||
}
|
||||
|
||||
// ---- Parity with the web parser (shared case file) ----
|
||||
|
||||
public static IEnumerable<object[]> SharedCases()
|
||||
{
|
||||
var doc = LoadCases();
|
||||
foreach (var c in doc.RootElement.GetProperty("cases").EnumerateArray())
|
||||
{
|
||||
// `extra` cases exercise client-derived tokens the server never sees.
|
||||
if (c.TryGetProperty("extra", out _)) continue;
|
||||
yield return new object[]
|
||||
{
|
||||
c.GetProperty("query").GetString()!,
|
||||
c.GetProperty("expected").GetBoolean(),
|
||||
c.GetProperty("note").GetString()!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SharedCases))]
|
||||
public void SharedCase_MatchesLikeTheWebParser(string query, bool expected, string note)
|
||||
{
|
||||
var session = SessionFromCaseFile();
|
||||
Assert.True(expected == SessionSearchQuery.Parse(query).Matches(session), $"{query} — {note}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedCaseFile_SampleSession_IsTheOneTheTestsUse()
|
||||
{
|
||||
// The case file's session drives both sides; the local Sample() mirrors it so the
|
||||
// InlineData theories above and the shared cases talk about the same rows.
|
||||
var fromFile = SessionFromCaseFile();
|
||||
var local = Sample();
|
||||
foreach (var field in SessionSearchQuery.Fields)
|
||||
Assert.Equal(field.Value(local), field.Value(fromFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedCaseFile_CoversEveryQualifier_AndEveryFieldHasAValue()
|
||||
{
|
||||
var doc = LoadCases();
|
||||
var queries = string.Join("\n", doc.RootElement.GetProperty("cases").EnumerateArray()
|
||||
.Select(c => c.GetProperty("query").GetString()!.ToLowerInvariant()));
|
||||
var session = SessionFromCaseFile();
|
||||
foreach (var field in SessionSearchQuery.Fields)
|
||||
{
|
||||
Assert.True(queries.Contains(field.Key + "=") || queries.Contains(field.Key + ":"),
|
||||
$"no shared case uses {field.Key}=");
|
||||
Assert.False(string.IsNullOrEmpty(field.Value(session)), $"sample carries no {field.Key}");
|
||||
}
|
||||
Assert.Equal(SessionSearchQuery.Fields.Count, SessionSearchQuery.Fields.Select(f => f.Key).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ResolvesKnownQualifiers_AndKeepsUnknownOnesLiteral()
|
||||
{
|
||||
var q = SessionSearchQuery.Parse("Model=Surface -status:failed 14:30 foo=bar");
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
new SessionSearchQuery.Term("Surface", "model"),
|
||||
new SessionSearchQuery.Term("14:30", null),
|
||||
new SessionSearchQuery.Term("foo=bar", null),
|
||||
}, q.Include);
|
||||
Assert.Equal(new[] { new SessionSearchQuery.Term("failed", "status") }, q.Exclude);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LoneMinusOrBareQualifier_IsEmpty()
|
||||
{
|
||||
Assert.True(SessionSearchQuery.Parse(" - model= ").IsEmpty);
|
||||
Assert.True(SessionSearchQuery.Parse(null).IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_BindsQ_SoContinuationCannotCrossQueries()
|
||||
{
|
||||
@@ -81,4 +162,45 @@ public class SessionFreeTextSearchTests
|
||||
Assert.NotEqual(fp1, fp2);
|
||||
Assert.NotEqual(fp1, fp3);
|
||||
}
|
||||
|
||||
private static JsonDocument LoadCases()
|
||||
{
|
||||
var path = Path.Combine(FindRepoRoot(), CasesRepoPath.Replace('/', Path.DirectorySeparatorChar));
|
||||
return JsonDocument.Parse(File.ReadAllText(path));
|
||||
}
|
||||
|
||||
private static SessionSummary SessionFromCaseFile()
|
||||
{
|
||||
var s = LoadCases().RootElement.GetProperty("session");
|
||||
string F(string name) => s.GetProperty(name).GetString()!;
|
||||
return new SessionSummary
|
||||
{
|
||||
DeviceName = F("deviceName"),
|
||||
SerialNumber = F("serialNumber"),
|
||||
Manufacturer = F("manufacturer"),
|
||||
Model = F("model"),
|
||||
Status = Enum.Parse<SessionStatus>(F("status")),
|
||||
SessionId = F("sessionId"),
|
||||
GeoCountry = F("geoCountry"),
|
||||
GeoRegion = F("geoRegion"),
|
||||
GeoCity = F("geoCity"),
|
||||
AgentVersion = F("agentVersion"),
|
||||
OsName = F("osName"),
|
||||
OsBuild = F("osBuild"),
|
||||
OsDisplayVersion = F("osDisplayVersion"),
|
||||
OsEdition = F("osEdition"),
|
||||
OsLanguage = F("osLanguage"),
|
||||
};
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,13 +60,26 @@ public sealed class SharedManifestParityTests
|
||||
// v2: adds the "types" section (full wire-type graph) and drops v1's
|
||||
// "sessionSummary" (it had no TS consumer; SessionSummary now rides in "types").
|
||||
["schemaVersion"] = 2,
|
||||
// "defaults" = the value of every wire field on a freshly constructed instance (the C#
|
||||
// initializer): the web's "custom" highlight compares stored values against these
|
||||
// instead of a hand-copied map. Nested objects appear as null — they have their own section.
|
||||
["adminConfiguration"] = new Dictionary<string, object?>
|
||||
{
|
||||
["fields"] = WireFieldNames(typeof(AdminConfiguration)),
|
||||
["defaults"] = WireDefaults(typeof(AdminConfiguration)),
|
||||
},
|
||||
["tenantConfiguration"] = new Dictionary<string, object?>
|
||||
{
|
||||
["fields"] = WireFieldNames(typeof(TenantConfiguration)),
|
||||
["defaults"] = WireDefaults(typeof(TenantConfiguration)),
|
||||
},
|
||||
// What an agent receives (GET agent/config) at its class defaults — the runtime column of
|
||||
// the Tenant Config Report highlights every effective value that differs from these.
|
||||
["agentConfig"] = new Dictionary<string, object?>
|
||||
{
|
||||
["responseDefaults"] = WireDefaults(typeof(AgentConfigResponse)),
|
||||
["collectorDefaults"] = WireDefaults(typeof(CollectorConfiguration)),
|
||||
["analyzerDefaults"] = WireDefaults(typeof(AnalyzerConfiguration)),
|
||||
},
|
||||
// Enum member order is load-bearing (append-only ordinals) — declaration order kept.
|
||||
["sessionStatuses"] = Enum.GetNames(typeof(SessionStatus)),
|
||||
@@ -116,6 +129,29 @@ public sealed class SharedManifestParityTests
|
||||
private static string[] WireFieldNames(Type type)
|
||||
=> WireProperties(type).Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Wire name → initializer value of a fresh instance. Scalars, strings, enums (by name),
|
||||
/// DateTime (round-trip) and arrays/lists of those travel as-is; a nested object is null.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object?> WireDefaults(Type type)
|
||||
{
|
||||
var instance = Activator.CreateInstance(type)!;
|
||||
return WireProperties(type).ToDictionary(
|
||||
p => JsonNamingPolicy.CamelCase.ConvertName(p.Name),
|
||||
p => DefaultValue(p.GetValue(instance)));
|
||||
}
|
||||
|
||||
private static object? DefaultValue(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
string s => s,
|
||||
bool or int or long or double or float or decimal => value,
|
||||
Enum e => e.ToString(),
|
||||
DateTime d => d.ToString("O"),
|
||||
System.Collections.IEnumerable list => list.Cast<object?>().Select(DefaultValue).ToArray(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static IEnumerable<PropertyInfo> WireProperties(Type type)
|
||||
=> type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.GetMethod != null && p.GetIndexParameters().Length == 0);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The two MCP usage counters go through <see cref="TableCasRetry"/> with a retry budget sized for
|
||||
/// parallel tool calls of one user (2026-09-06: the previous three-attempt loop without backoff lost
|
||||
/// 3 of 6 concurrent increments). These tests pin the budget, the merge-patch shape and the
|
||||
/// create-with-AddEntity path against a mocked table surface.
|
||||
/// </summary>
|
||||
public class TableUserUsageRepositoryCasTests
|
||||
{
|
||||
private sealed class TableHarness
|
||||
{
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<TableEntity> Added { get; } = new();
|
||||
public List<(TableEntity Entity, ETag ETag, TableUpdateMode Mode)> Updates { get; } = new();
|
||||
public Func<Response<TableEntity>> Read { get; set; } = () => throw new RequestFailedException(404, "not found");
|
||||
public Queue<Func<Response>> UpdateBehaviors { get; } = new();
|
||||
|
||||
public TableHarness()
|
||||
{
|
||||
Table.Setup(t => t.GetEntityAsync<TableEntity>(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(() => Task.FromResult(Read()));
|
||||
Table.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, CancellationToken>((e, _) =>
|
||||
{
|
||||
Added.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(t => t.UpdateEntityAsync(It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, tag, mode, _) =>
|
||||
{
|
||||
Updates.Add((e, tag, mode));
|
||||
return Task.FromResult(UpdateBehaviors.Count > 0 ? UpdateBehaviors.Dequeue()() : Mock.Of<Response>());
|
||||
});
|
||||
}
|
||||
|
||||
public static Response<TableEntity> Row(string pk, string rk, long count, string etag)
|
||||
{
|
||||
var e = new TableEntity(pk, rk) { ["RequestCount"] = count };
|
||||
e.ETag = new ETag(etag);
|
||||
return Response.FromValue(e, Mock.Of<Response>());
|
||||
}
|
||||
|
||||
public static Func<Response> Conflict => () => throw new RequestFailedException(412, "precondition failed");
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableHarness Users { get; } = new();
|
||||
public TableHarness Tenants { get; } = new();
|
||||
public TableUserUsageRepository Repo { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var service = new Mock<TableServiceClient>();
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.UserUsageLog)).Returns(Users.Table.Object);
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.McpTenantUsage)).Returns(Tenants.Table.Object);
|
||||
var storage = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
Repo = new TableUserUsageRepository(storage, NullLogger<TableUserUsageRepository>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retry_budget_is_sized_for_parallel_tool_calls()
|
||||
{
|
||||
Assert.Equal(8, TableUserUsageRepository.UsageCounterRetries);
|
||||
Assert.True(TableUserUsageRepository.UsageCounterRetries > TableCasRetry.DefaultRetries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Four_consecutive_etag_conflicts_still_land_the_user_increment()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Users.Read = () => TableHarness.Row("oid-1", "20260906_get_session_events:sessions_{id}_events", 5, "e1");
|
||||
for (var i = 0; i < 4; i++)
|
||||
h.Users.UpdateBehaviors.Enqueue(TableHarness.Conflict);
|
||||
|
||||
await h.Repo.IncrementUsageAsync("oid-1", "user@example.test", "tid-home", "get_session_events:sessions_{id}_events");
|
||||
|
||||
// The old loop gave up after three attempts; four conflicts plus the landing write = five updates.
|
||||
Assert.Equal(5, h.Users.Updates.Count);
|
||||
var (entity, etag, mode) = h.Users.Updates.Last();
|
||||
Assert.Equal(6L, entity.GetInt64("RequestCount"));
|
||||
Assert.Equal(new ETag("e1"), etag);
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Empty(h.Users.Added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_user_row_is_created_with_add_and_full_attribution()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.Repo.IncrementUsageAsync("oid-1", "user@example.test", "tid-home", "get_metrics:metrics");
|
||||
|
||||
var row = Assert.Single(h.Users.Added);
|
||||
Assert.Equal("oid-1", row.PartitionKey);
|
||||
Assert.EndsWith("_get_metrics:metrics", row.RowKey);
|
||||
Assert.Equal(1L, row.GetInt64("RequestCount"));
|
||||
Assert.Equal("user@example.test", row.GetString("UserPrincipalName"));
|
||||
Assert.Equal("tid-home", row.GetString("TenantId"));
|
||||
Assert.Equal("get_metrics:metrics", row.GetString("Endpoint"));
|
||||
Assert.Empty(h.Users.Updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_increment_merges_only_the_counter_and_attribution_columns()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Tenants.Read = () => TableHarness.Row("tid-customer", "20260906_oid-1", 41, "t1");
|
||||
|
||||
await h.Repo.IncrementTenantUsageAsync("tid-customer", "oid-1", "msp@example.test", "tid-home");
|
||||
|
||||
var (entity, etag, mode) = Assert.Single(h.Tenants.Updates);
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Equal(new ETag("t1"), etag);
|
||||
Assert.Equal(42L, entity.GetInt64("RequestCount"));
|
||||
Assert.Equal("msp@example.test", entity.GetString("UserPrincipalName"));
|
||||
Assert.Equal("tid-home", entity.GetString("HomeTenantId"));
|
||||
Assert.NotNull(entity["LastRequestAt"]);
|
||||
// A merge patch never re-sends the identity columns of the row.
|
||||
Assert.False(entity.ContainsKey("Date"));
|
||||
Assert.False(entity.ContainsKey("UserId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_increment_is_skipped_without_a_tenant_or_user()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.Repo.IncrementTenantUsageAsync("", "oid-1", null, null);
|
||||
await h.Repo.IncrementTenantUsageAsync("tid-customer", " ", null, null);
|
||||
|
||||
Assert.Empty(h.Tenants.Updates);
|
||||
Assert.Empty(h.Tenants.Added);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class UserWhatsNewSeenStorageTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string Upn = "user@contoso.com";
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserWhatsNewSeen_MissingRow_ReturnsNullMarks()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Table.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
TenantId,
|
||||
TableStorageService.PresenceRowKey(Upn),
|
||||
It.Is<IEnumerable<string>>(s => s.SequenceEqual(new[] { "WhatsNewSeenPlatformUtc", "WhatsNewSeenAgentUtc" })),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not found"));
|
||||
|
||||
var seen = await h.Sut.GetUserWhatsNewSeenAsync(TenantId, Upn);
|
||||
|
||||
Assert.Null(seen.PlatformUtc);
|
||||
Assert.Null(seen.AgentUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkUserWhatsNewSeen_OlderThanExisting_DoesNotWrite()
|
||||
{
|
||||
var existing = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var h = new Harness();
|
||||
h.SetupRead(new TableEntity(TenantId, TableStorageService.PresenceRowKey(Upn))
|
||||
{
|
||||
["WhatsNewSeenPlatformUtc"] = existing
|
||||
});
|
||||
|
||||
await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "platform", existing.AddMinutes(-1));
|
||||
|
||||
Assert.Empty(h.Upserts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkUserWhatsNewSeen_NewerValue_MergeUpsertsOnlySelectedChannelAndUpn()
|
||||
{
|
||||
var existing = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var newer = existing.AddMinutes(1);
|
||||
var h = new Harness();
|
||||
h.SetupRead(new TableEntity(TenantId, TableStorageService.PresenceRowKey(Upn))
|
||||
{
|
||||
["WhatsNewSeenAgentUtc"] = existing
|
||||
});
|
||||
|
||||
await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "agent", newer);
|
||||
|
||||
var (entity, mode) = Assert.Single(h.Upserts);
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Equal(Upn, entity.GetString("Upn"));
|
||||
Assert.Equal(newer, entity.GetDateTime("WhatsNewSeenAgentUtc"));
|
||||
Assert.False(entity.ContainsKey("LastSeen"));
|
||||
Assert.False(entity.ContainsKey("UserRole"));
|
||||
Assert.False(entity.ContainsKey("WhatsNewSeenPlatformUtc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkUserWhatsNewSeen_MissingRow_MergeCreatesRowWithUpn()
|
||||
{
|
||||
var seen = new DateTime(2026, 9, 8, 10, 0, 0, DateTimeKind.Utc);
|
||||
var h = new Harness();
|
||||
h.Table.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
TenantId,
|
||||
TableStorageService.PresenceRowKey(Upn),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not found"));
|
||||
|
||||
await h.Sut.MarkUserWhatsNewSeenAsync(TenantId, Upn, "platform", seen);
|
||||
|
||||
var (entity, mode) = Assert.Single(h.Upserts);
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Equal(TenantId, entity.PartitionKey);
|
||||
Assert.Equal(TableStorageService.PresenceRowKey(Upn), entity.RowKey);
|
||||
Assert.Equal(Upn, entity.GetString("Upn"));
|
||||
Assert.Equal(seen, entity.GetDateTime("WhatsNewSeenPlatformUtc"));
|
||||
Assert.False(entity.ContainsKey("LastSeen"));
|
||||
Assert.False(entity.ContainsKey("UserRole"));
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public TableStorageService Sut { get; }
|
||||
public List<(TableEntity Entity, TableUpdateMode Mode)> Upserts { get; } = new();
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(t => t.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(),
|
||||
It.IsAny<TableUpdateMode>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, mode, _) =>
|
||||
{
|
||||
Upserts.Add((e, mode));
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(Constants.TableNames.UserPresence)).Returns(Table.Object);
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
|
||||
public void SetupRead(TableEntity entity)
|
||||
{
|
||||
Table.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
entity.PartitionKey,
|
||||
entity.RowKey,
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(entity, Mock.Of<Response>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,24 @@ public class VulnerabilitySummaryAggregatorTests
|
||||
Assert.Equal(4, r.DistinctCves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildsPriorityBreakdownFromTheMergedBand_legacyRowsCountInNone()
|
||||
{
|
||||
var act = Row("CVE-A", "s1", "Critical", 9.8, kev: true); act.Priority = "act";
|
||||
var attendHigh = Row("CVE-B", "s1", "Medium", 5.0); attendHigh.Priority = "attend";
|
||||
var attendLow = Row("CVE-B", "s2", "Medium", 5.0); attendLow.Priority = "track"; // merged band = attend
|
||||
var track = Row("CVE-C", "s2", "Low", 2.0); track.Priority = "TRACK"; // case-insensitive
|
||||
var legacy = Row("CVE-D", "s3", "High", 7.5); // empty priority
|
||||
|
||||
var r = VulnerabilitySummaryAggregator.Aggregate(new[] { act, attendHigh, attendLow, track, legacy }, false, 20, 30, false, Now);
|
||||
|
||||
Assert.Equal(1, r.PriorityBreakdown.Act);
|
||||
Assert.Equal(1, r.PriorityBreakdown.Attend);
|
||||
Assert.Equal(1, r.PriorityBreakdown.Track);
|
||||
Assert.Equal(4, r.DistinctCves);
|
||||
Assert.Equal(r.KevCves, r.PriorityBreakdown.Act);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RanksTopCvesByAffectedSessionsThenScore_andRespectsTopN()
|
||||
{
|
||||
|
||||
@@ -663,6 +663,7 @@ public class VulnerabilityWireParityTests
|
||||
public void CveExposureSummary_wire_shape_is_unchanged_by_the_IApiResponse_marker()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 1, High = 2, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 1, Attend = 1, Track = 1 };
|
||||
var topCves = new List<TopCve>
|
||||
{
|
||||
new TopCve
|
||||
@@ -687,6 +688,7 @@ public class VulnerabilityWireParityTests
|
||||
distinctCves = 3,
|
||||
kevCves = 1,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
truncated = false,
|
||||
},
|
||||
@@ -698,6 +700,7 @@ public class VulnerabilityWireParityTests
|
||||
DistinctCves = 3,
|
||||
KevCves = 1,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
Truncated = false,
|
||||
});
|
||||
@@ -707,6 +710,7 @@ public class VulnerabilityWireParityTests
|
||||
public void CveExposureSummary_omits_totalAffectedTenants_on_a_tenant_scoped_read()
|
||||
{
|
||||
var breakdown = new SeverityBreakdown { Critical = 0, High = 0, Medium = 0, Low = 0 };
|
||||
var priorities = new PriorityBreakdown { Act = 0, Attend = 0, Track = 0 };
|
||||
var topCves = new List<TopCve>();
|
||||
|
||||
AssertParity(
|
||||
@@ -718,6 +722,7 @@ public class VulnerabilityWireParityTests
|
||||
distinctCves = 0,
|
||||
kevCves = 0,
|
||||
severityBreakdown = breakdown,
|
||||
priorityBreakdown = priorities,
|
||||
topCves,
|
||||
truncated = true,
|
||||
},
|
||||
@@ -729,6 +734,7 @@ public class VulnerabilityWireParityTests
|
||||
DistinctCves = 0,
|
||||
KevCves = 0,
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
Truncated = true,
|
||||
});
|
||||
|
||||
+6
@@ -92,6 +92,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
|
||||
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
|
||||
|
||||
public Task<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn)
|
||||
=> _storage.GetUserWhatsNewSeenAsync(tenantId, upn);
|
||||
|
||||
public Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc)
|
||||
=> _storage.MarkUserWhatsNewSeenAsync(tenantId, upn, channel, seenUtc);
|
||||
|
||||
public Task<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window)
|
||||
=> _storage.GetActivePresenceAsync(window);
|
||||
|
||||
|
||||
+62
-93
@@ -34,57 +34,46 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
_tenantTableClient = storage.GetTableClient(Constants.TableNames.McpTenantUsage);
|
||||
}
|
||||
|
||||
public async Task IncrementUsageAsync(string userId, string userPrincipalName, string tenantId, string endpoint)
|
||||
/// <summary>
|
||||
/// CAS attempts for the two usage counters — deliberately above
|
||||
/// <see cref="TableCasRetry.DefaultRetries"/>. MCP clients fan tool calls out in parallel, so
|
||||
/// six requests of one user hit the same (user, day, endpoint) row in the same instant; with
|
||||
/// the helper's jittered backoff one writer lands per round, and eight rounds absorb a burst
|
||||
/// of that size without dropping an increment (2026-09-06: three attempts without backoff lost
|
||||
/// 3 of 6). The increment runs off the request path, so the extra latency is invisible.
|
||||
/// </summary>
|
||||
internal const int UsageCounterRetries = 8;
|
||||
|
||||
private static long ReadCount(TableEntity entity)
|
||||
=> entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L;
|
||||
|
||||
public Task IncrementUsageAsync(string userId, string userPrincipalName, string tenantId, string endpoint)
|
||||
{
|
||||
var date = DateTime.UtcNow.ToString("yyyyMMdd");
|
||||
var rowKey = $"{date}_{endpoint}";
|
||||
|
||||
const int maxRetries = 3;
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
return TableCasRetry.MutateAsync(
|
||||
_tableClient, userId, rowKey,
|
||||
patch: read => new TableEntity(userId, rowKey)
|
||||
{
|
||||
var result = await _tableClient.GetEntityAsync<TableEntity>(userId, rowKey);
|
||||
var entity = result.Value;
|
||||
var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L;
|
||||
entity["RequestCount"] = count + 1;
|
||||
entity["LastRequestAt"] = DateTimeOffset.UtcNow;
|
||||
await _tableClient.UpdateEntityAsync(entity, entity.ETag);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
["RequestCount"] = ReadCount(read) + 1,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
},
|
||||
createMissing: () => new TableEntity(userId, rowKey)
|
||||
{
|
||||
var entity = new TableEntity(userId, rowKey)
|
||||
{
|
||||
["Date"] = date,
|
||||
["Endpoint"] = endpoint,
|
||||
["UserId"] = userId,
|
||||
["UserPrincipalName"] = userPrincipalName,
|
||||
["TenantId"] = tenantId,
|
||||
["RequestCount"] = 1L,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
};
|
||||
try
|
||||
{
|
||||
await _tableClient.AddEntityAsync(entity);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException addEx) when (addEx.Status == 409)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementUserUsage", Constants.TableNames.UserUsageLog, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementUserUsage", Constants.TableNames.UserUsageLog, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_metrics?.CasConflict("IncrementUserUsage", Constants.TableNames.UserUsageLog, CasOutcome.Exhausted);
|
||||
_logger.LogWarning("Failed to increment user usage after {MaxRetries} retries: user={UserId}, endpoint={Endpoint}",
|
||||
maxRetries, LogSanitizer.Clean(userId), LogSanitizer.Clean(endpoint));
|
||||
["Date"] = date,
|
||||
["Endpoint"] = endpoint,
|
||||
["UserId"] = userId,
|
||||
["UserPrincipalName"] = userPrincipalName,
|
||||
["TenantId"] = tenantId,
|
||||
["RequestCount"] = 1L,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
},
|
||||
operation: "IncrementUserUsage",
|
||||
tableName: Constants.TableNames.UserUsageLog,
|
||||
metrics: _metrics,
|
||||
logger: _logger,
|
||||
retries: UsageCounterRetries);
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageRecord>> GetUsageByUserAsync(string userId, string? dateFrom = null, string? dateTo = null)
|
||||
@@ -215,65 +204,45 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
|
||||
// ---- McpTenantUsage (organization-wide quota counters) ----
|
||||
|
||||
public async Task IncrementTenantUsageAsync(string tenantId, string userId, string? userPrincipalName, string? homeTenantId)
|
||||
public Task IncrementTenantUsageAsync(string tenantId, string userId, string? userPrincipalName, string? homeTenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userId))
|
||||
return;
|
||||
return Task.CompletedTask;
|
||||
|
||||
var date = DateTime.UtcNow.ToString("yyyyMMdd");
|
||||
var rowKey = $"{date}_{userId}";
|
||||
|
||||
const int maxRetries = 3;
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
return TableCasRetry.MutateAsync(
|
||||
_tenantTableClient, tenantId, rowKey,
|
||||
patch: read =>
|
||||
{
|
||||
var result = await _tenantTableClient.GetEntityAsync<TableEntity>(tenantId, rowKey);
|
||||
var entity = result.Value;
|
||||
var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L;
|
||||
entity["RequestCount"] = count + 1;
|
||||
entity["LastRequestAt"] = DateTimeOffset.UtcNow;
|
||||
// Attribution columns (added 2026-09): the UPN as last seen, and the caller's home tenant
|
||||
// when this row was charged by a delegated (MSP) read. Refreshed on every increment so a
|
||||
// row created before the columns existed heals on its next hit.
|
||||
if (!string.IsNullOrEmpty(userPrincipalName))
|
||||
entity["UserPrincipalName"] = userPrincipalName;
|
||||
entity["HomeTenantId"] = homeTenantId ?? string.Empty;
|
||||
await _tenantTableClient.UpdateEntityAsync(entity, entity.ETag);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
var entity = new TableEntity(tenantId, rowKey)
|
||||
var patch = new TableEntity(tenantId, rowKey)
|
||||
{
|
||||
["Date"] = date,
|
||||
["UserId"] = userId,
|
||||
["UserPrincipalName"] = userPrincipalName ?? string.Empty,
|
||||
["HomeTenantId"] = homeTenantId ?? string.Empty,
|
||||
["RequestCount"] = 1L,
|
||||
["RequestCount"] = ReadCount(read) + 1,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
// Attribution columns (added 2026-09): the UPN as last seen, and the caller's home
|
||||
// tenant when this row was charged by a delegated (MSP) read. Refreshed on every
|
||||
// increment so a row created before the columns existed heals on its next hit.
|
||||
["HomeTenantId"] = homeTenantId ?? string.Empty,
|
||||
};
|
||||
try
|
||||
{
|
||||
await _tenantTableClient.AddEntityAsync(entity);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException addEx) when (addEx.Status == 409)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementTenantUsage", Constants.TableNames.McpTenantUsage, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
if (!string.IsNullOrEmpty(userPrincipalName))
|
||||
patch["UserPrincipalName"] = userPrincipalName;
|
||||
return patch;
|
||||
},
|
||||
createMissing: () => new TableEntity(tenantId, rowKey)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementTenantUsage", Constants.TableNames.McpTenantUsage, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_metrics?.CasConflict("IncrementTenantUsage", Constants.TableNames.McpTenantUsage, CasOutcome.Exhausted);
|
||||
_logger.LogWarning("Failed to increment tenant usage after {MaxRetries} retries: tenant={TenantId}, user={UserId}",
|
||||
maxRetries, LogSanitizer.Clean(tenantId), LogSanitizer.Clean(userId));
|
||||
["Date"] = date,
|
||||
["UserId"] = userId,
|
||||
["UserPrincipalName"] = userPrincipalName ?? string.Empty,
|
||||
["HomeTenantId"] = homeTenantId ?? string.Empty,
|
||||
["RequestCount"] = 1L,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
},
|
||||
operation: "IncrementTenantUsage",
|
||||
tableName: Constants.TableNames.McpTenantUsage,
|
||||
metrics: _metrics,
|
||||
logger: _logger,
|
||||
retries: UsageCounterRetries);
|
||||
}
|
||||
|
||||
public async Task<List<TenantUsageRecord>> GetTenantUsageAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
|
||||
|
||||
+16
-1
@@ -180,17 +180,32 @@ public class TenantGroupManagementFunction
|
||||
/// BEFORE the delete removes them, so the bulk access removal is audited under each affected
|
||||
/// tenant and EVERY (former) assignee's live streams are cut — group authorization is join-time
|
||||
/// only, so without the kick they keep receiving the managed tenants' telemetry until their
|
||||
/// connection drops.
|
||||
/// connection drops. Deleting an OWNED group also ends conferred Pro for every member at once,
|
||||
/// so each member's retention grace anchor is stamped (the projection leaves no trace by itself).
|
||||
/// </summary>
|
||||
internal async Task DeleteGroupCoreAsync(string groupId, string? currentUpn)
|
||||
{
|
||||
var group = await _delegatedAdminService.GetGroupAsync(groupId);
|
||||
var assignees = await _delegatedAdminService.GetGroupAssigneesAsync(groupId);
|
||||
var owner = string.IsNullOrWhiteSpace(group?.OwnerTenantId) ? null : group!.OwnerTenantId;
|
||||
await _delegatedAdminService.DeleteGroupAsync(groupId);
|
||||
|
||||
foreach (var assignee in assignees)
|
||||
await _signalRService.DisconnectUserAsync(assignee.Upn);
|
||||
|
||||
// The portal hides Delete for owned groups, but the route accepts any group id — without the
|
||||
// anchor a member that raised its retention under Pro (MSP) would be clamped to the Community
|
||||
// cap at the next sweep instead of getting the grace period.
|
||||
if (owner != null)
|
||||
{
|
||||
foreach (var member in group!.TenantIds)
|
||||
{
|
||||
if (string.Equals(member, owner, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
await _proConferral.RecordLossAsync(member, owner, "group-deleted");
|
||||
}
|
||||
}
|
||||
|
||||
if (group != null && group.AssigneeCount > 0)
|
||||
{
|
||||
await AuditPerTenantAsync(group.TenantIds, "DELETE", "*", currentUpn,
|
||||
|
||||
+18
-205
@@ -20,8 +20,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private readonly ILogger<GetAgentConfigFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly GatherRuleService _gatherRuleService;
|
||||
private readonly ImeLogPatternService _imeLogPatternService;
|
||||
private readonly AgentConfigResolver _resolver;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
@@ -35,8 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
ILogger<GetAgentConfigFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
GatherRuleService gatherRuleService,
|
||||
ImeLogPatternService imeLogPatternService,
|
||||
AgentConfigResolver resolver,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -49,8 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_gatherRuleService = gatherRuleService;
|
||||
_imeLogPatternService = imeLogPatternService;
|
||||
_resolver = resolver;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
@@ -105,108 +102,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the major-version from an X-Agent-Version header value.
|
||||
/// Accepts SemVer-ish strings like "2.0.114" or "2.0.114+abc123".
|
||||
/// Missing/unparsable → returns 1 (backward-compat: very old agents may omit the
|
||||
/// header). The V1 line is retired, so major 1 resolves to empty hashes via
|
||||
/// GetAgentLine's default arm — legacy stragglers just skip their integrity check.
|
||||
/// Deliberately NOT defaulting to 2: that would hand V2 hashes to V1 binaries and
|
||||
/// could trigger the runtime_hash_mismatch force-update path against the wrong line.
|
||||
/// </summary>
|
||||
internal static int ParseAgentMajor(string? agentVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentVersion))
|
||||
return 1;
|
||||
|
||||
var dot = agentVersion.IndexOf('.');
|
||||
var majorStr = dot > 0 ? agentVersion.Substring(0, dot) : agentVersion;
|
||||
return int.TryParse(majorStr, out var major) ? major : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether the agent should perform diagnostics uploads at all.
|
||||
/// The CustomerSas destination is gated on a per-tenant SAS URL being present, but the
|
||||
/// Hosted destination has no such URL (the platform owns the storage) — so gating purely
|
||||
/// on the SAS URL silently disabled uploads for every Hosted-destination tenant. Enable
|
||||
/// when either a customer SAS is configured OR the destination is Hosted.
|
||||
/// </summary>
|
||||
internal static bool ResolveDiagnosticsUploadEnabled(string? diagnosticsBlobSasUrl, string? destination)
|
||||
{
|
||||
return !string.IsNullOrEmpty(diagnosticsBlobSasUrl)
|
||||
|| string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global ∪ tenant diagnostics paths: global first, order preserved, blank paths dropped,
|
||||
/// duplicates (trimmed, case-insensitive) collapsed onto the first occurrence. The agent
|
||||
/// keys ZIP entries on the path, so a path present in both lists would otherwise produce
|
||||
/// duplicate entry names inside the archive.
|
||||
/// </summary>
|
||||
internal static List<DiagnosticsLogPath> MergeDiagnosticsLogPaths(
|
||||
IEnumerable<DiagnosticsLogPath> global,
|
||||
IEnumerable<DiagnosticsLogPath> tenant)
|
||||
{
|
||||
return global.Concat(tenant)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p?.Path))
|
||||
.GroupBy(p => p.Path.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the (ZipSha256, ExeSha256) pair appropriate for the calling agent.
|
||||
/// Reads the X-Agent-Version header, parses the major, and dispatches to the
|
||||
/// corresponding per-line field set on <see cref="AdminConfiguration"/> via
|
||||
/// <see cref="AdminConfiguration.GetAgentLine(int)"/>. Future V3 = no change here;
|
||||
/// add a switch arm in GetAgentLine and a field set on AdminConfiguration.
|
||||
/// </summary>
|
||||
internal static (string ZipSha256, string ExeSha256) SelectAgentHashesForClient(
|
||||
HttpRequestData req,
|
||||
AutopilotMonitor.Shared.Models.AdminConfiguration adminConfig)
|
||||
{
|
||||
var agentVersion = req.Headers.Contains("X-Agent-Version")
|
||||
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault()
|
||||
: null;
|
||||
|
||||
var line = adminConfig.GetAgentLine(ParseAgentMajor(agentVersion));
|
||||
return (line.ZipSha256, line.ExeSha256);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the endpoint-migration target for a tenant: per-tenant override wins over
|
||||
/// the global value; an override entry with an empty value pins the tenant (no
|
||||
/// migration even while the global target is set — staged rollout). The winning value
|
||||
/// is validated against <see cref="AutopilotMonitor.Shared.Services.AgentEndpointMigrationRules"/>;
|
||||
/// invalid values resolve to null (fail-safe: better to strand an agent on the old
|
||||
/// backend than to serve a broken or non-allowlisted URL).
|
||||
/// </summary>
|
||||
internal static string? ResolveMigrateTarget(
|
||||
AutopilotMonitor.Shared.Models.AdminConfiguration adminConfig,
|
||||
string tenantId,
|
||||
out string? rejectedCandidate)
|
||||
{
|
||||
rejectedCandidate = null;
|
||||
string? candidate = adminConfig.AgentMigrateApiBaseUrl;
|
||||
|
||||
var overrides = adminConfig.GetAgentMigrateTenantOverrides();
|
||||
if (!string.IsNullOrEmpty(tenantId) && overrides.TryGetValue(tenantId, out var tenantTarget))
|
||||
candidate = tenantTarget; // empty string = pinned, handled below
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
return null;
|
||||
|
||||
if (AutopilotMonitor.Shared.Services.AgentEndpointMigrationRules
|
||||
.TryNormalizeTarget(candidate, out var normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
rejectedCandidate = candidate;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core config logic: fetch tenant + admin config, gather rules, IME patterns.
|
||||
/// Core config logic: kill verdict for this device, then the shared derivation
|
||||
/// (<see cref="AgentConfigResolver"/>) with the device verdict applied on top.
|
||||
/// Called by both the cert-auth Run() method and the bootstrap wrapper.
|
||||
/// <paramref name="intuneDeviceId"/> is the certificate identity from the cert Subject CN
|
||||
/// (cert-auth callers); the bootstrap wrapper has none and leaves it null.
|
||||
@@ -236,114 +133,30 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
intuneDeviceId: intuneDeviceId);
|
||||
DeviceIdentityBinding.Stamp(req, killVerdict.IdentityBinding);
|
||||
|
||||
// Get tenant configuration
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
// Select the per-line hash oracle from the X-Agent-Version header (parametric per major).
|
||||
var resolution = await _resolver.ResolveAsync(tenantId, AgentConfigResolver.ParseAgentMajor(agentVersionHeader));
|
||||
var response = resolution.Response;
|
||||
|
||||
// Get global admin config for platform-wide policy settings
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
|
||||
// Build collector configuration from tenant settings + global policy
|
||||
var collectors = new CollectorConfiguration
|
||||
{
|
||||
EnablePerformanceCollector = tenantConfig.EnablePerformanceCollector,
|
||||
PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds,
|
||||
CollectorIdleTimeoutMinutes = adminConfig.CollectorIdleTimeoutMinutes,
|
||||
DesktopDetectorNoCandidateTimeoutMinutes = adminConfig.DesktopDetectorNoCandidateTimeoutMinutes,
|
||||
HelloWaitTimeoutSeconds = tenantConfig.HelloWaitTimeoutSeconds,
|
||||
AgentMaxLifetimeMinutes = tenantConfig.AgentMaxLifetimeMinutes ?? 360,
|
||||
ModernDeploymentHarmlessEventIds = adminConfig.GetModernDeploymentHarmlessEventIds().ToArray()
|
||||
};
|
||||
|
||||
// Active gather rules + active IME log patterns for this tenant. Independent reads,
|
||||
// both served from per-instance catalog caches — fetch them concurrently.
|
||||
var gatherRulesTask = _gatherRuleService.GetActiveRulesForTenantAsync(tenantId);
|
||||
var imeLogPatternsTask = _imeLogPatternService.GetActivePatternsForTenantAsync(tenantId);
|
||||
await Task.WhenAll(gatherRulesTask, imeLogPatternsTask);
|
||||
var gatherRules = await gatherRulesTask;
|
||||
var imeLogPatterns = await imeLogPatternsTask;
|
||||
|
||||
// Merge global + tenant-specific diagnostics log paths (the built-in sections are
|
||||
// compiled into the agent — DiagnosticsBuiltInSections — and never travel here)
|
||||
var diagLogPaths = MergeDiagnosticsLogPaths(
|
||||
adminConfig.GetDiagnosticsGlobalLogPaths(), tenantConfig.GetDiagnosticsLogPaths());
|
||||
|
||||
// Select per-line hash oracle from the X-Agent-Version header (parametric per major).
|
||||
// The wire response keeps generic field names (LatestAgentSha256 / LatestAgentExeSha256)
|
||||
// so agent code is unchanged across all major lines.
|
||||
var (latestAgentSha256, latestAgentExeSha256) = SelectAgentHashesForClient(req, adminConfig);
|
||||
|
||||
// Endpoint migration on the control channel: serve the (validated) re-home target
|
||||
// so agents still bound to this backend's compiled-in URL move themselves to the
|
||||
// new deployment at their next start. LogWarning (not Information) because worker
|
||||
// logs below Warning never reach App Insights — this line is the delivery evidence
|
||||
// during a migration window.
|
||||
var migrateTarget = ResolveMigrateTarget(adminConfig, tenantId, out var rejectedMigrateCandidate);
|
||||
if (migrateTarget != null)
|
||||
// LogWarning (not Information) because worker logs below Warning never reach App
|
||||
// Insights — this line is the delivery evidence during a migration window.
|
||||
if (response.MigrateToApiBaseUrl != null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AgentMigrateServed: tenant={TenantId} serial={Serial} agentVersion={AgentVersion} target={Target}",
|
||||
tenantId, serialNumberHeader, agentVersionHeader, migrateTarget);
|
||||
tenantId, serialNumberHeader, agentVersionHeader, response.MigrateToApiBaseUrl);
|
||||
}
|
||||
else if (rejectedMigrateCandidate != null)
|
||||
else if (resolution.RejectedMigrateCandidate != null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AgentMigrateRejected: configured migration target failed validation and is NOT served. tenant={TenantId} candidate={Candidate}",
|
||||
tenantId, rejectedMigrateCandidate);
|
||||
tenantId, resolution.RejectedMigrateCandidate);
|
||||
}
|
||||
|
||||
var response = await req.OkAsync(new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 40, // EnableDoGroupIdAutoSet (Delivery Optimization group ID from network fingerprint)
|
||||
UploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
EnableGeoLocation = tenantConfig.EnableGeoLocation ?? true,
|
||||
EnableImeMatchLog = tenantConfig.EnableImeMatchLog ?? false,
|
||||
EnableGatherRuleDebugLog = tenantConfig.EnableGatherRuleDebugLog ?? false,
|
||||
EnableEspContinueAnywayObservation = tenantConfig.EnableEspContinueAnywayObservation ?? false,
|
||||
MaxAuthFailures = tenantConfig.MaxAuthFailures ?? 5,
|
||||
AuthFailureTimeoutMinutes = tenantConfig.AuthFailureTimeoutMinutes ?? 0,
|
||||
LogLevel = tenantConfig.LogLevel ?? "Info",
|
||||
RebootOnComplete = tenantConfig.RebootOnComplete ?? false,
|
||||
RebootDelaySeconds = tenantConfig.RebootDelaySeconds ?? 10,
|
||||
ShowEnrollmentSummary = tenantConfig.ShowEnrollmentSummary ?? false,
|
||||
EnrollmentSummaryTimeoutSeconds = tenantConfig.EnrollmentSummaryTimeoutSeconds ?? 60,
|
||||
EnrollmentSummaryBrandingImageUrl = tenantConfig.EnrollmentSummaryBrandingImageUrl,
|
||||
EnrollmentSummaryLaunchRetrySeconds = tenantConfig.EnrollmentSummaryLaunchRetrySeconds ?? 120,
|
||||
MaxBatchSize = tenantConfig.MaxBatchSize ?? 100,
|
||||
DiagnosticsUploadEnabled = ResolveDiagnosticsUploadEnabled(
|
||||
tenantConfig.DiagnosticsBlobSasUrl, tenantConfig.DiagnosticsUploadDestination),
|
||||
DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off",
|
||||
DiagnosticsLogPaths = diagLogPaths,
|
||||
Collectors = collectors,
|
||||
Analyzers = new AnalyzerConfiguration
|
||||
{
|
||||
EnableLocalAdminAnalyzer = tenantConfig.EnableLocalAdminAnalyzer ?? true,
|
||||
LocalAdminAllowedAccounts = tenantConfig.GetLocalAdminAllowedAccounts(),
|
||||
EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? false,
|
||||
EnableIntegrityBypassAnalyzer = tenantConfig.EnableIntegrityBypassAnalyzer ?? true,
|
||||
EnableRealmJoinWatcher = tenantConfig.EnableRealmJoinWatcher ?? false,
|
||||
KeepAwakeDuringUserEsp = tenantConfig.KeepAwakeDuringUserEsp ?? false,
|
||||
EnableConsoleBypassDetection = tenantConfig.EnableConsoleBypassDetection ?? true
|
||||
},
|
||||
LatestAgentSha256 = latestAgentSha256,
|
||||
LatestAgentExeSha256 = latestAgentExeSha256,
|
||||
AllowAgentDowngrade = adminConfig.AllowAgentDowngrade,
|
||||
NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer,
|
||||
EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false,
|
||||
EnableDoGroupIdAutoSet = tenantConfig.EnableDoGroupIdAutoSet ?? false,
|
||||
SendTraceEvents = tenantConfig.SendTraceEvents,
|
||||
UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(tenantConfig, DateTime.UtcNow),
|
||||
GatherRules = gatherRules,
|
||||
ImeLogPatterns = imeLogPatterns,
|
||||
WhiteGloveSealingPatternIds = adminConfig.GetWhiteGloveSealingPatternIds(),
|
||||
DeviceBlocked = killVerdict.IsBlocked,
|
||||
DeviceKillSignal = killVerdict.IsKill,
|
||||
UnblockAt = killVerdict.UnblockAt,
|
||||
MigrateToApiBaseUrl = migrateTarget,
|
||||
});
|
||||
response.DeviceBlocked = killVerdict.IsBlocked;
|
||||
response.DeviceKillSignal = killVerdict.IsKill;
|
||||
response.UnblockAt = killVerdict.UnblockAt;
|
||||
|
||||
return response;
|
||||
return await req.OkAsync(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Global-Admin view of what an agent of the tenant receives from GET agent/config right
|
||||
/// now: the same <see cref="AgentConfigResolver"/> derivation the agent channel uses, minus
|
||||
/// the per-device kill verdict (no device here). Drives the runtime column of the Tenant
|
||||
/// Config Report, so the report cannot drift from the backend rules. Read-only, no
|
||||
/// side effects, no ops event.
|
||||
/// </summary>
|
||||
public class GetEffectiveAgentConfigFunction
|
||||
{
|
||||
private readonly ILogger<GetEffectiveAgentConfigFunction> _logger;
|
||||
private readonly AgentConfigResolver _resolver;
|
||||
|
||||
public GetEffectiveAgentConfigFunction(
|
||||
ILogger<GetEffectiveAgentConfigFunction> logger,
|
||||
AgentConfigResolver resolver)
|
||||
{
|
||||
_logger = logger;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
[Function("GetEffectiveAgentConfig")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}/effective-agent-config")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// GlobalAdminOnly + RouteParam scoping enforced by PolicyEnforcementMiddleware.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
// Optional ?agentVersion= selects the hash-oracle line like the agent's
|
||||
// X-Agent-Version header would; absent → the current (V2) line, never the
|
||||
// legacy fallback an absent header means on the agent channel.
|
||||
var agentVersion = System.Web.HttpUtility.ParseQueryString(req.Url.Query)["agentVersion"];
|
||||
var agentMajor = string.IsNullOrWhiteSpace(agentVersion)
|
||||
? AgentConfigResolver.CurrentAgentMajor
|
||||
: AgentConfigResolver.ParseAgentMajor(agentVersion);
|
||||
|
||||
var resolution = await _resolver.ResolveAsync(requestCtx.TargetTenantId, agentMajor);
|
||||
return await req.OkAsync(resolution.Response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetEffectiveAgentConfig");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,8 @@ public class AuthFunction
|
||||
|
||||
// --- Parallel data fetch: all independent queries run concurrently ---
|
||||
// Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500.
|
||||
// This is intentional — all 6 queries are required for the auth decision.
|
||||
// This is intentional — the auth-decision queries are required; the What's-new marker read
|
||||
// is fail-soft inside the repository because it is only a UX convenience.
|
||||
// Non-creating read: do NOT auto-persist a default config row just because someone authenticated.
|
||||
// A delegated-only external MSP login must not phantom-onboard its home tenant (see the side-effect
|
||||
// gate below). A genuine first-user still gets their config created by HandleNewTenantDomainAsync.
|
||||
@@ -116,9 +117,10 @@ public class AuthFunction
|
||||
// middleware does (the table-vs-claim reconciliation happens inside the shared role resolver).
|
||||
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId, principal.GetAppRoles());
|
||||
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
|
||||
var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(tenantId, upn);
|
||||
|
||||
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
|
||||
membershipTask, mcpCheckTask, existingAdminsTask);
|
||||
membershipTask, mcpCheckTask, existingAdminsTask, whatsNewSeenTask);
|
||||
|
||||
var (tenantConfig, _) = tenantConfigTask.Result;
|
||||
var globalRole = globalRoleTask.Result;
|
||||
@@ -131,6 +133,7 @@ public class AuthFunction
|
||||
var (tableState, tableRole) = membershipTask.Result;
|
||||
var mcpCheck = mcpCheckTask.Result;
|
||||
var existingAdmins = existingAdminsTask.Result;
|
||||
var whatsNewSeen = whatsNewSeenTask.Result;
|
||||
|
||||
// Reconcile the TenantAdmins table state with any Entra app-role claim. An enabled row
|
||||
// wins; a disabled row is an explicit deny (claim ignored); only a missing row falls back
|
||||
@@ -164,7 +167,9 @@ public class AuthFunction
|
||||
memberRole, mcpCheck, existingAdmins.Count > 0,
|
||||
tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
|
||||
delegatedTenantIds,
|
||||
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary");
|
||||
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary",
|
||||
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
|
||||
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc);
|
||||
|
||||
if (!decision.IsSuccess)
|
||||
{
|
||||
@@ -193,6 +198,35 @@ public class AuthFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PUT /api/auth/me/whats-new-seen
|
||||
/// Marks the caller's What's-new channel as seen.
|
||||
/// </summary>
|
||||
[Function("MarkWhatsNewSeen")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> MarkWhatsNewSeen(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "auth/me/whats-new-seen")] HttpRequestData req,
|
||||
FunctionContext context)
|
||||
{
|
||||
var principal = context.GetUser();
|
||||
if (principal == null)
|
||||
return req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
|
||||
var tenantId = principal.GetTenantId();
|
||||
var upn = principal.GetUserPrincipalName();
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
|
||||
return await req.BadRequestAsync("Missing required claims");
|
||||
|
||||
var read = await req.ReadAsync<WhatsNewSeenRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
|
||||
if (!TryNormalizeWhatsNewSeen(read.Value, DateTime.UtcNow, out var channel, out var seenUtc, out var error))
|
||||
return await req.BadRequestAsync(error);
|
||||
|
||||
await _metricsRepo.MarkUserWhatsNewSeenAsync(tenantId, upn, channel, seenUtc);
|
||||
return req.CreateResponse(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/auth/is-global-admin
|
||||
/// Checks if the current user is a Global Admin
|
||||
@@ -515,7 +549,9 @@ public class AuthFunction
|
||||
bool hasTenantAdmins,
|
||||
string tenantId, string upn, string displayName, string objectId,
|
||||
IReadOnlyCollection<string>? delegatedTenantIds = null,
|
||||
string homedApp = "primary")
|
||||
string homedApp = "primary",
|
||||
DateTime? whatsNewSeenPlatformUtc = null,
|
||||
DateTime? whatsNewSeenAgentUtc = null)
|
||||
{
|
||||
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
|
||||
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
|
||||
@@ -603,10 +639,61 @@ public class AuthFunction
|
||||
BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
|
||||
UnrestrictedModeEnabled =
|
||||
FeatureEntitlementCatalog.Get(TenantEntitlementService.Resolve(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
|
||||
&& tenantConfig.UnrestrictedModeEnabled
|
||||
&& tenantConfig.UnrestrictedModeEnabled,
|
||||
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
|
||||
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc
|
||||
}, needsAutoAdmin);
|
||||
}
|
||||
|
||||
internal static bool TryNormalizeWhatsNewSeen(
|
||||
WhatsNewSeenRequest? body,
|
||||
DateTime nowUtc,
|
||||
out string channel,
|
||||
out DateTime seenUtc,
|
||||
out string error)
|
||||
{
|
||||
channel = string.Empty;
|
||||
seenUtc = default;
|
||||
error = string.Empty;
|
||||
|
||||
if (body == null)
|
||||
{
|
||||
error = "Request body is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
channel = (body.Channel ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (channel is not ("platform" or "agent"))
|
||||
{
|
||||
error = "channel must be 'platform' or 'agent'";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (body.SeenUtc == default)
|
||||
{
|
||||
error = "seenUtc is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
nowUtc = NormalizeUtc(nowUtc);
|
||||
seenUtc = NormalizeUtc(body.SeenUtc);
|
||||
if (seenUtc > nowUtc.AddMinutes(5))
|
||||
{
|
||||
// Client clocks can run ahead; clamp rather than reject so the marker remains harmless.
|
||||
seenUtc = nowUtc;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DateTime NormalizeUtc(DateTime value)
|
||||
=> value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Extracts domain name from UPN (e.g., user@contoso.com -> contoso.com)
|
||||
/// </summary>
|
||||
|
||||
+8
@@ -100,6 +100,13 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
Low = groups.Count(c => c.SeverityRank == 1),
|
||||
};
|
||||
|
||||
var priorities = new PriorityBreakdown
|
||||
{
|
||||
Act = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Act)),
|
||||
Attend = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Attend)),
|
||||
Track = groups.Count(c => CvePriority.Rank(c.Priority) == CvePriority.Rank(CvePriority.Track)),
|
||||
};
|
||||
|
||||
var totalAffectedSessions = inWindow.Select(r => r.SessionId)
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
@@ -118,6 +125,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
DistinctCves = groups.Count,
|
||||
KevCves = groups.Count(c => c.IsKev),
|
||||
SeverityBreakdown = breakdown,
|
||||
PriorityBreakdown = priorities,
|
||||
TopCves = topCves,
|
||||
Truncated = truncated,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,16 @@ public static class EndpointNormalizer
|
||||
@"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
// Same classes LogSanitizer strips: C0/C1 control characters (Table Storage rejects them in
|
||||
// keys) plus the Unicode line/paragraph separators a log viewer breaks a line on.
|
||||
private static readonly Regex ControlChars = new(@"[\p{Cc}\p{Zl}\p{Zp}]", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Cap for the caller-supplied tool-name prefix of a usage row key. Real MCP tool names are
|
||||
/// well under this; the cap only bounds what an arbitrary header can push into a key.
|
||||
/// </summary>
|
||||
public const int MaxToolNameLength = 64;
|
||||
|
||||
public static string Normalize(string path)
|
||||
{
|
||||
// Strip /api/ prefix
|
||||
@@ -22,9 +32,32 @@ public static class EndpointNormalizer
|
||||
// Replace GUIDs with {id}
|
||||
normalized = GuidPattern.Replace(normalized, "{id}");
|
||||
|
||||
// Replace characters that are illegal in Azure Table Storage keys (/, \, #, ?)
|
||||
normalized = normalized.Replace('/', '_').Replace('\\', '_').Replace('#', '_').Replace('?', '_');
|
||||
|
||||
return normalized.ToLowerInvariant();
|
||||
return ReplaceIllegalKeyChars(normalized).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes the <c>X-MCP-Tool-Name</c> header value safe to use as the prefix of a usage row key.
|
||||
/// The header is caller-supplied (the MCP server sets it to the real tool name, but any
|
||||
/// authenticated MCP-source caller can send it), and the key ends up in the CAS-conflict warning
|
||||
/// of the usage counter: control characters and line separators are removed, the characters Table
|
||||
/// Storage rejects in keys are replaced, and the length is capped. A real tool name
|
||||
/// (<c>get_session_events</c>) passes through unchanged, so existing usage rows keep their keys.
|
||||
/// Returns an empty string when nothing key-safe remains; callers then skip the prefix.
|
||||
/// </summary>
|
||||
public static string ToolNameKey(string? toolName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(toolName))
|
||||
return string.Empty;
|
||||
|
||||
// Explicit CR/LF removal first — the log-injection barrier the CodeQL taint model
|
||||
// recognizes; the regex pass then drops any remaining control character or separator.
|
||||
var cleaned = toolName.Replace("\r", string.Empty).Replace("\n", string.Empty);
|
||||
cleaned = ControlChars.Replace(cleaned, string.Empty);
|
||||
cleaned = ReplaceIllegalKeyChars(cleaned).Trim();
|
||||
return cleaned.Length <= MaxToolNameLength ? cleaned : cleaned[..MaxToolNameLength];
|
||||
}
|
||||
|
||||
/// <summary>Replaces the characters Azure Table Storage rejects in keys (/, \, #, ?).</summary>
|
||||
private static string ReplaceIllegalKeyChars(string value)
|
||||
=> value.Replace('/', '_').Replace('\\', '_').Replace('#', '_').Replace('?', '_');
|
||||
}
|
||||
|
||||
@@ -287,13 +287,13 @@ public static class MetricsMath
|
||||
|
||||
private static List<FleetModelHealth> BuildFleetModelHealth(IReadOnlyList<SessionSummary> sessions)
|
||||
{
|
||||
var models = new Dictionary<string, FleetModelHealth>();
|
||||
var models = new Dictionary<ModelBucket, FleetModelHealth>();
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
var key = FleetModelKey(s);
|
||||
if (!models.TryGetValue(key, out var m))
|
||||
{
|
||||
m = new FleetModelHealth { Model = key };
|
||||
m = new FleetModelHealth { Manufacturer = key.Manufacturer, Model = key.Model, Label = key.Label };
|
||||
models[key] = m;
|
||||
}
|
||||
m.Total++;
|
||||
@@ -308,7 +308,7 @@ public static class MetricsMath
|
||||
|
||||
private static List<FleetSlowModel> BuildFleetSlowestModels(IReadOnlyList<SessionSummary> sessions)
|
||||
{
|
||||
var acc = new Dictionary<string, (long TotalDuration, int Count)>();
|
||||
var acc = new Dictionary<ModelBucket, (long TotalDuration, int Count)>();
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
if (s.Status != SessionStatus.Succeeded) continue;
|
||||
@@ -320,7 +320,9 @@ public static class MetricsMath
|
||||
return acc
|
||||
.Select(kv => new FleetSlowModel
|
||||
{
|
||||
Model = kv.Key,
|
||||
Manufacturer = kv.Key.Manufacturer,
|
||||
Model = kv.Key.Model,
|
||||
Label = kv.Key.Label,
|
||||
AvgMinutes = (int)Math.Round((double)kv.Value.TotalDuration / kv.Value.Count / 60.0, MidpointRounding.AwayFromZero),
|
||||
Count = kv.Value.Count,
|
||||
})
|
||||
@@ -331,7 +333,7 @@ public static class MetricsMath
|
||||
|
||||
private static List<FleetFailingModel> BuildFleetTopFailingModels(IReadOnlyList<SessionSummary> sessions)
|
||||
{
|
||||
var acc = new Dictionary<string, (int Failed, int Succeeded, int Total)>();
|
||||
var acc = new Dictionary<ModelBucket, (int Failed, int Succeeded, int Total)>();
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
var key = FleetModelKey(s);
|
||||
@@ -347,7 +349,9 @@ public static class MetricsMath
|
||||
.Where(kv => kv.Value.Failed > 0)
|
||||
.Select(kv => new FleetFailingModel
|
||||
{
|
||||
Model = kv.Key,
|
||||
Manufacturer = kv.Key.Manufacturer,
|
||||
Model = kv.Key.Model,
|
||||
Label = kv.Key.Label,
|
||||
Failed = kv.Value.Failed,
|
||||
Total = kv.Value.Total,
|
||||
FailureRate = (int)Math.Round(
|
||||
@@ -358,13 +362,26 @@ public static class MetricsMath
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>"{Manufacturer} {Model}" trimmed, or "Unknown" when both are blank.</summary>
|
||||
private static string FleetModelKey(SessionSummary s)
|
||||
/// <summary>
|
||||
/// Grouping key of the model buckets: the two session fields as stored (trimmed) plus the
|
||||
/// display label "{Manufacturer} {Model}", or "Unknown" when both are blank. Grouping on the
|
||||
/// pair keeps ("Contoso", "A B") and ("Contoso A", "B") apart even though their labels collide.
|
||||
/// </summary>
|
||||
private readonly record struct ModelBucket(string Manufacturer, string Model)
|
||||
{
|
||||
var key = $"{s.Manufacturer} {s.Model}".Trim();
|
||||
return string.IsNullOrEmpty(key) ? "Unknown" : key;
|
||||
public string Label
|
||||
{
|
||||
get
|
||||
{
|
||||
var label = $"{Manufacturer} {Model}".Trim();
|
||||
return string.IsNullOrEmpty(label) ? "Unknown" : label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ModelBucket FleetModelKey(SessionSummary s)
|
||||
=> new((s.Manufacturer ?? string.Empty).Trim(), (s.Model ?? string.Empty).Trim());
|
||||
|
||||
/// <summary>Share of total bytes (0-100, one decimal) not pulled from the CDN. 0 when no bytes.</summary>
|
||||
private static double OffloadPercent(long offloaded, long total)
|
||||
=> total > 0 ? Math.Round((double)offloaded / total * 100, 1) : 0;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard search-box grammar, parsed once per request for the server-side sweep
|
||||
/// (<c>q=</c> on <c>/api/search/sessions</c> and the global twin). Mirrors the web's
|
||||
/// <c>app/dashboard/utils/sessionSearchQuery.ts</c> exactly — both run
|
||||
/// <c>utils/session-search-syntax.cases.json</c>, because a term that matches on one side
|
||||
/// and not the other is a ghost result (found by the server, filtered out by the client, or
|
||||
/// never fetched at all).
|
||||
/// <para>
|
||||
/// Grammar: whitespace-separated terms are AND-ed; a leading minus excludes; double quotes
|
||||
/// protect a phrase; <c>key=value</c> / <c>key:value</c> restricts a term to one field when
|
||||
/// <c>key</c> is in <see cref="Fields"/> (case-insensitive), otherwise the token stays a
|
||||
/// literal term (<c>14:30</c>). A qualifier is recognised only when the separator precedes any
|
||||
/// quote in the token. Every term is a case-insensitive substring match; free terms may hit
|
||||
/// any field in <see cref="Fields"/>. There is no escape character.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SessionSearchQuery
|
||||
{
|
||||
/// <summary>Qualifier → field reader, in the web's suggestion order. Exactly the set the client searches.</summary>
|
||||
public static readonly IReadOnlyList<KeyValuePair<string, Func<SessionSummary, string?>>> Fields =
|
||||
new List<KeyValuePair<string, Func<SessionSummary, string?>>>
|
||||
{
|
||||
new("device", s => s.DeviceName),
|
||||
new("serial", s => s.SerialNumber),
|
||||
new("model", s => s.Model),
|
||||
new("manufacturer", s => s.Manufacturer),
|
||||
new("status", s => s.Status.ToString()),
|
||||
new("session", s => s.SessionId),
|
||||
new("country", s => s.GeoCountry),
|
||||
new("region", s => s.GeoRegion),
|
||||
new("city", s => s.GeoCity),
|
||||
new("agent", s => s.AgentVersion),
|
||||
new("os", s => s.OsName),
|
||||
new("build", s => s.OsBuild),
|
||||
new("osversion", s => s.OsDisplayVersion),
|
||||
new("edition", s => s.OsEdition),
|
||||
new("language", s => s.OsLanguage),
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, Func<SessionSummary, string?>> ReaderByQualifier =
|
||||
Fields.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>One search term; <see cref="Field"/> is the qualifier (lowercase) or null for a free term.</summary>
|
||||
public readonly record struct Term(string Text, string? Field);
|
||||
|
||||
public IReadOnlyList<Term> Include { get; }
|
||||
public IReadOnlyList<Term> Exclude { get; }
|
||||
|
||||
/// <summary>True when the query carries no filter at all (matches every session).</summary>
|
||||
public bool IsEmpty => Include.Count == 0 && Exclude.Count == 0;
|
||||
|
||||
private SessionSearchQuery(List<Term> include, List<Term> exclude)
|
||||
{
|
||||
Include = include;
|
||||
Exclude = exclude;
|
||||
}
|
||||
|
||||
public static SessionSearchQuery Parse(string? query)
|
||||
{
|
||||
var include = new List<Term>();
|
||||
var exclude = new List<Term>();
|
||||
foreach (var token in Tokenize(query ?? string.Empty))
|
||||
{
|
||||
Term term;
|
||||
if (token.Qualifier != null && ReaderByQualifier.ContainsKey(token.Qualifier))
|
||||
term = new Term(token.Text.ToString(), token.Qualifier.ToLowerInvariant());
|
||||
else
|
||||
term = new Term(token.LiteralText, null);
|
||||
var target = token.Negated ? exclude : include;
|
||||
if (!target.Contains(term)) target.Add(term);
|
||||
}
|
||||
return new SessionSearchQuery(include, exclude);
|
||||
}
|
||||
|
||||
public bool Matches(SessionSummary session)
|
||||
{
|
||||
if (IsEmpty) return true;
|
||||
foreach (var t in Include)
|
||||
if (!TermMatches(t, session)) return false;
|
||||
foreach (var t in Exclude)
|
||||
if (TermMatches(t, session)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TermMatches(Term term, SessionSummary session)
|
||||
{
|
||||
if (term.Field != null)
|
||||
return ContainsIgnoreCase(ReaderByQualifier[term.Field](session), term.Text);
|
||||
foreach (var field in Fields)
|
||||
if (ContainsIgnoreCase(field.Value(session), term.Text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(string? haystack, string needle)
|
||||
=> !string.IsNullOrEmpty(haystack)
|
||||
&& haystack!.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
private sealed class Token
|
||||
{
|
||||
public StringBuilder Text { get; } = new();
|
||||
public bool Negated { get; init; }
|
||||
public string? Qualifier { get; set; }
|
||||
public char Separator { get; set; }
|
||||
public bool SawQuote { get; set; }
|
||||
public string LiteralText => Qualifier == null ? Text.ToString() : $"{Qualifier}{Separator}{Text}";
|
||||
}
|
||||
|
||||
// Character-for-character the web tokenizer (lib/searchQueryTokens.ts).
|
||||
private static List<Token> Tokenize(string query)
|
||||
{
|
||||
var tokens = new List<Token>();
|
||||
Token? current = null;
|
||||
var inQuotes = false;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (current != null && current.Text.Length > 0) tokens.Add(current);
|
||||
current = null;
|
||||
}
|
||||
|
||||
foreach (var ch in query)
|
||||
{
|
||||
if (!inQuotes && char.IsWhiteSpace(ch))
|
||||
{
|
||||
Flush();
|
||||
continue;
|
||||
}
|
||||
if (ch == '"')
|
||||
{
|
||||
current ??= new Token();
|
||||
current.SawQuote = true;
|
||||
inQuotes = !inQuotes;
|
||||
continue;
|
||||
}
|
||||
if (current == null)
|
||||
{
|
||||
if (ch == '-')
|
||||
{
|
||||
current = new Token { Negated = true };
|
||||
continue;
|
||||
}
|
||||
current = new Token();
|
||||
}
|
||||
if ((ch == '=' || ch == ':') && !inQuotes && !current.SawQuote
|
||||
&& current.Qualifier == null && current.Text.Length > 0)
|
||||
{
|
||||
current.Qualifier = current.Text.ToString();
|
||||
current.Separator = ch;
|
||||
current.Text.Clear();
|
||||
continue;
|
||||
}
|
||||
current.Text.Append(ch);
|
||||
}
|
||||
Flush();
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
@@ -354,8 +354,9 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
private void TrackUsage(HttpContext httpContext, string oid, string? upn, string homeTenantId, IReadOnlyCollection<string> chargeTenantIds)
|
||||
{
|
||||
var normalizedEndpoint = EndpointNormalizer.Normalize(httpContext.Request.Path.Value ?? string.Empty);
|
||||
var mcpToolName = httpContext.Request.Headers["X-MCP-Tool-Name"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(mcpToolName))
|
||||
// Key-safe copy of the header: the value becomes part of the UserUsageLog row key.
|
||||
var mcpToolName = EndpointNormalizer.ToolNameKey(httpContext.Request.Headers["X-MCP-Tool-Name"].FirstOrDefault());
|
||||
if (mcpToolName.Length > 0)
|
||||
normalizedEndpoint = $"{mcpToolName}:{normalizedEndpoint}";
|
||||
|
||||
var repo = _userUsageRepo;
|
||||
|
||||
@@ -310,6 +310,8 @@ builder.Services.AddSingleton<BlockedDeviceService>();
|
||||
builder.Services.AddSingleton<HardwareRejectionThrottleService>();
|
||||
builder.Services.AddSingleton<BlockedVersionService>();
|
||||
builder.Services.AddSingleton<KillSwitchEvaluator>();
|
||||
// Shared agent-config derivation for the agent channel and the GA effective-config route.
|
||||
builder.Services.AddSingleton<AgentConfigResolver>();
|
||||
builder.Services.AddSingleton<BlobStorageService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.HostedDiagnosticsBlobService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.DiagnosticsBlobStreamer>();
|
||||
|
||||
@@ -209,6 +209,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
|
||||
// ── AuthenticatedUser ───────────────────────────────────────────
|
||||
new("GET", "auth/me", EndpointPolicy.AuthenticatedUser),
|
||||
new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser),
|
||||
new("GET", "auth/is-global-admin", EndpointPolicy.AuthenticatedUser),
|
||||
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser),
|
||||
// Group join/leave is AuthenticatedUserWithRole — NOT MemberRead — on purpose. The real,
|
||||
@@ -458,6 +459,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
// config writes remain a future policy decision.
|
||||
new("PATCH", "config/{tenantId}/fields", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("GET", "config/{tenantId}/backups", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
// Operator view of the AgentConfigResponse an agent of the tenant receives (Tenant Config Report).
|
||||
new("GET", "config/{tenantId}/effective-agent-config", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/revert", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
// Field schema for the write surface above: tenant-independent reflection over the model
|
||||
// + deny-lists (no tenant data), but GA-only to match the surface it describes. Literal
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="AgentConfigResolver.Build"/>: the response an agent of the tenant
|
||||
/// receives, plus the migration candidate that failed validation (null when none) so the
|
||||
/// agent channel can log it as delivery evidence.
|
||||
/// </summary>
|
||||
public sealed class AgentConfigResolution
|
||||
{
|
||||
public AgentConfigResponse Response { get; }
|
||||
public string? RejectedMigrateCandidate { get; }
|
||||
|
||||
public AgentConfigResolution(AgentConfigResponse response, string? rejectedMigrateCandidate)
|
||||
{
|
||||
Response = response;
|
||||
RejectedMigrateCandidate = rejectedMigrateCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one place that derives an <see cref="AgentConfigResponse"/> from a tenant's stored
|
||||
/// configuration, the global admin configuration and the tenant's active rule catalogs.
|
||||
/// Serves two callers with the same result: the cert-authenticated agent channel
|
||||
/// (GetAgentConfigFunction, which adds the per-device kill verdict on top) and the
|
||||
/// Global-Admin report route (GetEffectiveAgentConfigFunction), so the operator sees
|
||||
/// exactly what an agent would receive. Every default applied here IS the agent-side
|
||||
/// default; the class initializers of <see cref="AgentConfigResponse"/> are the source
|
||||
/// of the "custom" highlight in the web report via the shared manifest.
|
||||
/// </summary>
|
||||
public class AgentConfigResolver
|
||||
{
|
||||
/// <summary>Bumped when the wire shape gains a field the agent must understand.</summary>
|
||||
public const int ConfigVersion = 40; // EnableDoGroupIdAutoSet (Delivery Optimization group ID from network fingerprint)
|
||||
|
||||
/// <summary>
|
||||
/// The agent line whose hash oracle is served when no X-Agent-Version is available
|
||||
/// (operator report). V2 is the only wired line — see AdminConfiguration.GetAgentLine.
|
||||
/// </summary>
|
||||
public const int CurrentAgentMajor = 2;
|
||||
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly GatherRuleService _gatherRuleService;
|
||||
private readonly ImeLogPatternService _imeLogPatternService;
|
||||
|
||||
public AgentConfigResolver(
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
GatherRuleService gatherRuleService,
|
||||
ImeLogPatternService imeLogPatternService)
|
||||
{
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_gatherRuleService = gatherRuleService;
|
||||
_imeLogPatternService = imeLogPatternService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the four inputs and builds the response. Rule and pattern reads are independent
|
||||
/// and served from per-instance catalog caches — fetched concurrently.
|
||||
/// </summary>
|
||||
public async Task<AgentConfigResolution> ResolveAsync(string tenantId, int agentMajor)
|
||||
{
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
|
||||
var gatherRulesTask = _gatherRuleService.GetActiveRulesForTenantAsync(tenantId);
|
||||
var imeLogPatternsTask = _imeLogPatternService.GetActivePatternsForTenantAsync(tenantId);
|
||||
await Task.WhenAll(gatherRulesTask, imeLogPatternsTask);
|
||||
|
||||
return Build(tenantConfig, adminConfig, await gatherRulesTask, await imeLogPatternsTask, agentMajor, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure derivation: tenant config + admin config + catalogs → response. Device-specific
|
||||
/// fields (DeviceBlocked, DeviceKillSignal, UnblockAt) stay at their defaults; the agent
|
||||
/// channel sets them from its kill verdict.
|
||||
/// </summary>
|
||||
public static AgentConfigResolution Build(
|
||||
TenantConfiguration tenantConfig,
|
||||
AdminConfiguration adminConfig,
|
||||
List<GatherRule> gatherRules,
|
||||
List<ImeLogPattern> imeLogPatterns,
|
||||
int agentMajor,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
// Collector configuration from tenant settings + global policy
|
||||
var collectors = new CollectorConfiguration
|
||||
{
|
||||
EnablePerformanceCollector = tenantConfig.EnablePerformanceCollector,
|
||||
PerformanceIntervalSeconds = tenantConfig.PerformanceCollectorIntervalSeconds,
|
||||
CollectorIdleTimeoutMinutes = adminConfig.CollectorIdleTimeoutMinutes,
|
||||
DesktopDetectorNoCandidateTimeoutMinutes = adminConfig.DesktopDetectorNoCandidateTimeoutMinutes,
|
||||
HelloWaitTimeoutSeconds = tenantConfig.HelloWaitTimeoutSeconds,
|
||||
AgentMaxLifetimeMinutes = tenantConfig.AgentMaxLifetimeMinutes ?? 360,
|
||||
ModernDeploymentHarmlessEventIds = adminConfig.GetModernDeploymentHarmlessEventIds().ToArray()
|
||||
};
|
||||
|
||||
// Merge global + tenant-specific diagnostics log paths (the built-in sections are
|
||||
// compiled into the agent — DiagnosticsBuiltInSections — and never travel here)
|
||||
var diagLogPaths = MergeDiagnosticsLogPaths(
|
||||
adminConfig.GetDiagnosticsGlobalLogPaths(), tenantConfig.GetDiagnosticsLogPaths());
|
||||
|
||||
// Per-line hash oracle (parametric per major). The wire response keeps generic field
|
||||
// names (LatestAgentSha256 / LatestAgentExeSha256) so agent code is unchanged across lines.
|
||||
var line = adminConfig.GetAgentLine(agentMajor);
|
||||
|
||||
// Endpoint migration on the control channel: the (validated) re-home target, so agents
|
||||
// still bound to this backend's compiled-in URL move themselves at their next start.
|
||||
var migrateTarget = ResolveMigrateTarget(adminConfig, tenantConfig.TenantId, out var rejectedMigrateCandidate);
|
||||
|
||||
var response = new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = ConfigVersion,
|
||||
UploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
EnableGeoLocation = tenantConfig.EnableGeoLocation ?? true,
|
||||
EnableImeMatchLog = tenantConfig.EnableImeMatchLog ?? false,
|
||||
EnableGatherRuleDebugLog = tenantConfig.EnableGatherRuleDebugLog ?? false,
|
||||
EnableEspContinueAnywayObservation = tenantConfig.EnableEspContinueAnywayObservation ?? false,
|
||||
MaxAuthFailures = tenantConfig.MaxAuthFailures ?? 5,
|
||||
AuthFailureTimeoutMinutes = tenantConfig.AuthFailureTimeoutMinutes ?? 0,
|
||||
LogLevel = tenantConfig.LogLevel ?? "Info",
|
||||
RebootOnComplete = tenantConfig.RebootOnComplete ?? false,
|
||||
RebootDelaySeconds = tenantConfig.RebootDelaySeconds ?? 10,
|
||||
ShowEnrollmentSummary = tenantConfig.ShowEnrollmentSummary ?? false,
|
||||
EnrollmentSummaryTimeoutSeconds = tenantConfig.EnrollmentSummaryTimeoutSeconds ?? 60,
|
||||
EnrollmentSummaryBrandingImageUrl = tenantConfig.EnrollmentSummaryBrandingImageUrl,
|
||||
EnrollmentSummaryLaunchRetrySeconds = tenantConfig.EnrollmentSummaryLaunchRetrySeconds ?? 120,
|
||||
MaxBatchSize = tenantConfig.MaxBatchSize ?? 100,
|
||||
DiagnosticsUploadEnabled = ResolveDiagnosticsUploadEnabled(
|
||||
tenantConfig.DiagnosticsBlobSasUrl, tenantConfig.DiagnosticsUploadDestination),
|
||||
DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off",
|
||||
DiagnosticsLogPaths = diagLogPaths,
|
||||
Collectors = collectors,
|
||||
Analyzers = new AnalyzerConfiguration
|
||||
{
|
||||
EnableLocalAdminAnalyzer = tenantConfig.EnableLocalAdminAnalyzer ?? true,
|
||||
LocalAdminAllowedAccounts = tenantConfig.GetLocalAdminAllowedAccounts(),
|
||||
EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? false,
|
||||
EnableIntegrityBypassAnalyzer = tenantConfig.EnableIntegrityBypassAnalyzer ?? true,
|
||||
EnableRealmJoinWatcher = tenantConfig.EnableRealmJoinWatcher ?? false,
|
||||
KeepAwakeDuringUserEsp = tenantConfig.KeepAwakeDuringUserEsp ?? false,
|
||||
EnableConsoleBypassDetection = tenantConfig.EnableConsoleBypassDetection ?? true
|
||||
},
|
||||
LatestAgentSha256 = line.ZipSha256,
|
||||
LatestAgentExeSha256 = line.ExeSha256,
|
||||
AllowAgentDowngrade = adminConfig.AllowAgentDowngrade,
|
||||
NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer,
|
||||
EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false,
|
||||
EnableDoGroupIdAutoSet = tenantConfig.EnableDoGroupIdAutoSet ?? false,
|
||||
SendTraceEvents = tenantConfig.SendTraceEvents,
|
||||
UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(tenantConfig, nowUtc),
|
||||
GatherRules = gatherRules,
|
||||
ImeLogPatterns = imeLogPatterns,
|
||||
WhiteGloveSealingPatternIds = adminConfig.GetWhiteGloveSealingPatternIds(),
|
||||
MigrateToApiBaseUrl = migrateTarget,
|
||||
};
|
||||
|
||||
return new AgentConfigResolution(response, rejectedMigrateCandidate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the major-version from an X-Agent-Version header value.
|
||||
/// Accepts SemVer-ish strings like "2.0.114" or "2.0.114+abc123".
|
||||
/// Missing/unparsable → returns 1 (backward-compat: very old agents may omit the
|
||||
/// header). The V1 line is retired, so major 1 resolves to empty hashes via
|
||||
/// GetAgentLine's default arm — legacy stragglers just skip their integrity check.
|
||||
/// Deliberately NOT defaulting to 2: that would hand V2 hashes to V1 binaries and
|
||||
/// could trigger the runtime_hash_mismatch force-update path against the wrong line.
|
||||
/// </summary>
|
||||
internal static int ParseAgentMajor(string? agentVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentVersion))
|
||||
return 1;
|
||||
|
||||
var dot = agentVersion.IndexOf('.');
|
||||
var majorStr = dot > 0 ? agentVersion.Substring(0, dot) : agentVersion;
|
||||
return int.TryParse(majorStr, out var major) ? major : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether the agent should perform diagnostics uploads at all.
|
||||
/// The CustomerSas destination is gated on a per-tenant SAS URL being present, but the
|
||||
/// Hosted destination has no such URL (the platform owns the storage) — so gating purely
|
||||
/// on the SAS URL silently disabled uploads for every Hosted-destination tenant. Enable
|
||||
/// when either a customer SAS is configured OR the destination is Hosted.
|
||||
/// </summary>
|
||||
internal static bool ResolveDiagnosticsUploadEnabled(string? diagnosticsBlobSasUrl, string? destination)
|
||||
{
|
||||
return !string.IsNullOrEmpty(diagnosticsBlobSasUrl)
|
||||
|| string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global ∪ tenant diagnostics paths: global first, order preserved, blank paths dropped,
|
||||
/// duplicates (trimmed, case-insensitive) collapsed onto the first occurrence. The agent
|
||||
/// keys ZIP entries on the path, so a path present in both lists would otherwise produce
|
||||
/// duplicate entry names inside the archive.
|
||||
/// </summary>
|
||||
internal static List<DiagnosticsLogPath> MergeDiagnosticsLogPaths(
|
||||
IEnumerable<DiagnosticsLogPath> global,
|
||||
IEnumerable<DiagnosticsLogPath> tenant)
|
||||
{
|
||||
return global.Concat(tenant)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p?.Path))
|
||||
.GroupBy(p => p.Path.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the endpoint-migration target for a tenant: per-tenant override wins over
|
||||
/// the global value; an override entry with an empty value pins the tenant (no
|
||||
/// migration even while the global target is set — staged rollout). The winning value
|
||||
/// is validated against <see cref="AutopilotMonitor.Shared.Services.AgentEndpointMigrationRules"/>;
|
||||
/// invalid values resolve to null (fail-safe: better to strand an agent on the old
|
||||
/// backend than to serve a broken or non-allowlisted URL).
|
||||
/// </summary>
|
||||
internal static string? ResolveMigrateTarget(
|
||||
AdminConfiguration adminConfig,
|
||||
string tenantId,
|
||||
out string? rejectedCandidate)
|
||||
{
|
||||
rejectedCandidate = null;
|
||||
|
||||
string? candidate = adminConfig.AgentMigrateApiBaseUrl;
|
||||
var overrides = adminConfig.GetAgentMigrateTenantOverrides();
|
||||
if (!string.IsNullOrEmpty(tenantId) && overrides.TryGetValue(tenantId, out var tenantTarget))
|
||||
candidate = tenantTarget; // empty string = pinned, handled below
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
return null;
|
||||
|
||||
if (AutopilotMonitor.Shared.Services.AgentEndpointMigrationRules
|
||||
.TryNormalizeTarget(candidate, out var normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
rejectedCandidate = candidate;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
@@ -33,7 +32,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
if (config == null) return false;
|
||||
return !string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
|
||||
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
|
||||
&& AgentConfigResolver.ResolveDiagnosticsUploadEnabled(
|
||||
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
|
||||
@@ -18,9 +19,23 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Idempotent: when a sibling info key is already present the entry is skipped.
|
||||
/// Read-only on the storage layer — never writes back to the table.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Events emitted by the gather-rule pipeline (<see cref="GatherRuleSource"/>) are only
|
||||
/// enriched when the rule opted in via <see cref="GatherRule.EnrichErrorCodes"/> — the agent
|
||||
/// then stamps <c>enrichErrorCodes: true</c> into the data. Without it, their payload keys are
|
||||
/// author-chosen and the codes come from arbitrary third-party logs or commands (HP Image
|
||||
/// Assistant, Dell Command Update, custom scripts …) with their own exit-code numbering;
|
||||
/// resolving those against the Windows/MSI/Intune catalog would attach a confident but
|
||||
/// wrong meaning.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ErrorCodeEnricher
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="EnrollmentEvent.Source"/> stamped by the agent on every gather-rule event.
|
||||
/// </summary>
|
||||
internal const string GatherRuleSource = Constants.EventSources.GatherRuleExecutor;
|
||||
|
||||
/// <summary>
|
||||
/// Keys (lower-cased) whose value the enricher looks up. Match is case-insensitive.
|
||||
/// Each is paired with the sibling info-key written next to it in <c>Data</c>.
|
||||
@@ -42,6 +57,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public static void EnrichEvent(EnrollmentEvent evt)
|
||||
{
|
||||
if (evt?.Data == null || evt.Data.Count == 0) return;
|
||||
if (IsGatherRuleEvent(evt) && !HasEnrichOptIn(evt.Data)) return;
|
||||
|
||||
foreach (var pair in _codeKeyPairs)
|
||||
{
|
||||
@@ -87,6 +103,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
foreach (var e in events) EnrichEvent(e);
|
||||
}
|
||||
|
||||
private static bool IsGatherRuleEvent(EnrollmentEvent evt) =>
|
||||
string.Equals(evt.Source, GatherRuleSource, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The agent-stamped opt-in marker. Data may hold a CLR bool (in-process) or a
|
||||
/// deserialized JSON token / string (from DataJson), so the check goes via ToString.
|
||||
/// </summary>
|
||||
private static bool HasEnrichOptIn(Dictionary<string, object> data)
|
||||
{
|
||||
if (!TryFindKey(data, Constants.GatherRuleDataKeys.EnrichErrorCodes, out var key)) return false;
|
||||
var raw = data[key];
|
||||
if (raw is bool b) return b;
|
||||
return string.Equals(raw?.ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryFindKey(Dictionary<string, object> data, string lowerKey, out string actualKey)
|
||||
{
|
||||
foreach (var k in data.Keys)
|
||||
|
||||
@@ -505,7 +505,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
&& a.TriggerPhase == b.TriggerPhase
|
||||
&& ActivePhasesEquivalent(a.ActivePhases, b.ActivePhases)
|
||||
&& a.ActiveFromPhase == b.ActiveFromPhase
|
||||
&& a.EmitMode == b.EmitMode;
|
||||
&& a.EmitMode == b.EmitMode
|
||||
&& a.EnrichErrorCodes == b.EnrichErrorCodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -49,10 +49,12 @@ public class HealthCheckService
|
||||
};
|
||||
|
||||
// NOTE: CheckMcpServerAsync is deliberately NOT part of this batch. The MCP
|
||||
// Container App scales to zero on idle, so a probe may need to wake it and can
|
||||
// take many seconds. Bundling it here would block the entire dashboard on that
|
||||
// one slow check. It is exposed via its own endpoint (GET health/mcp) that the
|
||||
// frontend fetches independently and folds into the card grid incrementally.
|
||||
// Container App scales to zero on idle; a cold probe answers "warming" after a
|
||||
// short budget and the caller re-polls until the replica is up. That re-poll loop
|
||||
// must stay off the blocking batch, and "warming" must never reach this aggregate
|
||||
// — a scale-from-zero is expected behaviour, not a degraded platform. It is
|
||||
// exposed via its own endpoint (GET health/mcp) that the frontend fetches
|
||||
// independently and folds into the card grid incrementally.
|
||||
var checks = await Task.WhenAll(
|
||||
CheckStorageBackendAsync(includeEndpointUrls),
|
||||
CheckProcessingBackendAsync(includeEndpointUrls),
|
||||
@@ -244,13 +246,32 @@ public class HealthCheckService
|
||||
/// only included in the details when <paramref name="includeEndpointUrl"/> is set —
|
||||
/// callers must only enable it for Global Admins.
|
||||
/// <para>
|
||||
/// The Container App runs with minReplicas=0 and scales to zero on idle. Azure Container
|
||||
/// Apps holds an inbound request while it activates a cold replica, so this probe doubles
|
||||
/// as the wake signal — a generous timeout (<c>McpServerHealthTimeoutSeconds</c>, default
|
||||
/// 30s) gives the cold start time to complete and report healthy. Only when the server
|
||||
/// cannot be woken within that budget is it a warning; a reachable-but-erroring server
|
||||
/// (non-2xx) is unhealthy. This is exposed via its own <c>health/mcp</c> endpoint, never
|
||||
/// the blocking all-checks batch, so the dashboard never waits on the wake.
|
||||
/// The Container App runs with minReplicas=0 and scales to zero on idle, and a cold start
|
||||
/// is dominated by Container Apps activation, not by us: measured at 24.5s and 31.5s to
|
||||
/// first byte, of which ~98% is KEDA scheduling plus the image mount and ~500ms is the
|
||||
/// application (see <c>internal/docs/mcp/docs-corpus.md</c>, "Cold start"). No UI wait
|
||||
/// covers that, so this probe deliberately does not try to sit it out — an earlier design
|
||||
/// waited 30s as a "wake signal" and still timed out in 50 of 83 observed calls, i.e. it
|
||||
/// bought a long wall in front of the same answer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The probe therefore has two jobs: answer fast and honestly within
|
||||
/// <c>McpServerHealthTimeoutSeconds</c> (default 3s — chosen to sit in the widest observed
|
||||
/// latency gap, 2347ms..3930ms, so jitter cannot flip the verdict), and leave the wake
|
||||
/// running. The inbound request has already reached the Container Apps activator by the
|
||||
/// time we give up, so aborting our side does not undo the scale-from-zero it triggered;
|
||||
/// the caller re-checks and a later probe finds the replica warm.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Status vocabulary: <c>healthy</c> = answered 2xx; <c>warming</c> = did not answer inside
|
||||
/// the budget, a cold start is in progress and the caller should re-check (must NOT be
|
||||
/// rated into any aggregate — it is expected, not a fault); <c>warning</c> = reachable but
|
||||
/// the connection failed; <c>unhealthy</c> = answered non-2xx. Exposed via its own
|
||||
/// <c>health/mcp</c> endpoint, never the blocking all-checks batch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>McpServerHealthTimeoutSeconds</c> is set nowhere in the repo, infra or workflows, so
|
||||
/// the default below IS the live production value — changing it changes production.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal async Task<HealthCheck> CheckMcpServerAsync(bool includeEndpointUrl = false)
|
||||
@@ -268,11 +289,9 @@ public class HealthCheckService
|
||||
{
|
||||
check.Details = new Dictionary<string, object> { ["Server URL"] = baseUrl };
|
||||
}
|
||||
var timeoutSeconds = ParsePositiveInt("McpServerHealthTimeoutSeconds", 30);
|
||||
|
||||
// A cold start that takes longer than this is treated as a successful wake rather
|
||||
// than an instant-warm hit, so the message can say so.
|
||||
const long WarmHitThresholdMs = 2500;
|
||||
// 3s: warm answers were measured at 4..148ms over 14 days, so this is ~20x the warm
|
||||
// maximum, while a cold start needs 20-30s and is unreachable for any UI wait.
|
||||
var timeoutSeconds = ParsePositiveInt("McpServerHealthTimeoutSeconds", 3);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -293,24 +312,34 @@ public class HealthCheckService
|
||||
var version = await TryReadMcpVersionAsync(response);
|
||||
|
||||
check.Status = "healthy";
|
||||
check.Message = sw.ElapsedMilliseconds > WarmHitThresholdMs
|
||||
? $"MCP server woke from idle and is reachable ({sw.ElapsedMilliseconds}ms)"
|
||||
: $"MCP server reachable ({sw.ElapsedMilliseconds}ms)";
|
||||
check.Message = McpReachableMessage(sw.ElapsedMilliseconds);
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
check.Details ??= new Dictionary<string, object>();
|
||||
check.Details["Version"] = version;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException or HttpRequestException)
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// Timeout or connection failure after the full wake budget. The probe held the
|
||||
// request long enough for a cold replica to activate, so a failure here means the
|
||||
// server could not be woken — surface as a warning (not a hard outage: the next
|
||||
// re-check may still succeed once it finishes warming).
|
||||
// Our own timeout: no external cancellation token reaches this path, so a
|
||||
// cancellation here is always the budget above expiring. The scale-from-zero we
|
||||
// triggered keeps running after we give up, so this is a re-check prompt, not a
|
||||
// fault — and with minReplicas=0 it is the expected path for an idle server,
|
||||
// which is why it logs at Information and must not colour any aggregate.
|
||||
check.Status = "warming";
|
||||
// Short on purpose: the portal card renders its own one-line warming text and
|
||||
// ignores this message, so it only has to be honest for a direct API caller.
|
||||
check.Message = $"MCP server is starting (no answer within {timeoutSeconds}s; it scales to zero when idle)";
|
||||
_logger.LogInformation(ex, "MCP server health probe found a cold server (no answer within {TimeoutSeconds}s); activation continues in the background", timeoutSeconds);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// Reached the network but could not connect — distinct from the cold-start path
|
||||
// above and genuinely worth a warning. ex.Message can carry the host name, so it
|
||||
// stays out of the user-visible text (same rule as the non-2xx branch).
|
||||
check.Status = "warning";
|
||||
check.Message = $"MCP server could not be reached within {timeoutSeconds}s — it may still be warming up. Re-check in a moment.";
|
||||
_logger.LogWarning(ex, "MCP server health probe did not respond within {TimeoutSeconds}s (wake attempt failed)", timeoutSeconds);
|
||||
check.Message = "MCP server unreachable (connection failed)";
|
||||
_logger.LogWarning(ex, "MCP server health probe could not connect");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -322,6 +351,24 @@ public class HealthCheckService
|
||||
return check;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An answer slower than this came from a replica that had just started, not from a warm
|
||||
/// one, so the message can say so. 750ms is ~5x the warm maximum measured over 14 days
|
||||
/// (4..148ms) and deliberately generous: those measurements come from a near-idle,
|
||||
/// single-user system, and warm latency will rise under concurrency. It stays well below
|
||||
/// the next observed cluster (2289ms), so the distinction remains sharp.
|
||||
/// </summary>
|
||||
private const long WarmHitThresholdMs = 750;
|
||||
|
||||
/// <summary>
|
||||
/// Wording for a reachable MCP server. Extracted so the threshold can be pinned by a test
|
||||
/// without a real delay. Both arms keep the word "reachable".
|
||||
/// </summary>
|
||||
internal static string McpReachableMessage(long elapsedMs) =>
|
||||
elapsedMs > WarmHitThresholdMs
|
||||
? $"MCP server reachable after a cold start ({elapsedMs}ms)"
|
||||
: $"MCP server reachable ({elapsedMs}ms)";
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort parse of the MCP <c>/health</c> JSON body for its <c>version</c> field.
|
||||
/// A missing or malformed body is non-fatal — the probe already proved reachability.
|
||||
|
||||
@@ -594,7 +594,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Free-text queries backfill whole Azure pages until pageSize matches accumulated
|
||||
// (bounded rounds); non-q queries keep the single-page contract ("consume until
|
||||
// absent" — a page may contain fewer than pageSize items after client filters).
|
||||
var maxRounds = string.IsNullOrEmpty(filter.Q) ? 1 : FreeTextBackfillMaxRounds;
|
||||
var query = SessionSearchQuery.Parse(filter.Q);
|
||||
var maxRounds = query.IsEmpty ? 1 : FreeTextBackfillMaxRounds;
|
||||
|
||||
var sessions = new List<SessionSummary>(pageSize);
|
||||
var token = continuation;
|
||||
@@ -610,7 +611,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var session = MapIndexEntityToSessionSummary(entity);
|
||||
if (!MatchesScanClientFilters(session, filter)) continue;
|
||||
if (!MatchesScanClientFilters(session, filter, query)) continue;
|
||||
sessions.Add(session);
|
||||
}
|
||||
|
||||
@@ -645,11 +646,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
|
||||
var query = SessionSearchQuery.Parse(filter.Q);
|
||||
var rows = new List<IReadOnlyDictionary<string, object?>>(entities.Count);
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var session = MapIndexEntityToSessionSummary(entity);
|
||||
if (!MatchesScanClientFilters(session, filter)) continue;
|
||||
if (!MatchesScanClientFilters(session, filter, query)) continue;
|
||||
rows.Add(RawEntityProjection.ToDictionary(entity));
|
||||
}
|
||||
|
||||
@@ -782,40 +784,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Free-text predicate for the dashboard search box (<c>q=</c>). Case-insensitive
|
||||
/// substring over EXACTLY the fields the web dashboard's client-side filter searches
|
||||
/// (useDashboardFilters.searchableText, minus its derived-only tokens: localized date
|
||||
/// string, "N min" duration, "blocked"). Keep the two lists in sync — a field matched
|
||||
/// only server-side would be filtered back out by the client and read as a ghost result.
|
||||
/// Free-text predicate for the dashboard search box (<c>q=</c>): the search grammar
|
||||
/// (<see cref="SessionSearchQuery"/> — AND-ed terms, exclusions, phrases, field
|
||||
/// qualifiers) over EXACTLY the fields the web dashboard's client-side filter searches
|
||||
/// (minus its derived-only tokens: localized date string, "N min" duration, "blocked").
|
||||
/// The two sides share a case file — a field matched only server-side would be filtered
|
||||
/// back out by the client and read as a ghost result. Parses per call; the page paths
|
||||
/// parse once and use <see cref="MatchesScanClientFilters(SessionSummary, SessionSearchFilter, SessionSearchQuery)"/>.
|
||||
/// </summary>
|
||||
internal static bool MatchesFreeText(SessionSummary session, string? q)
|
||||
=> SessionSearchQuery.Parse(q).Matches(session);
|
||||
|
||||
private static bool MatchesScanClientFilters(SessionSummary session, SessionSearchFilter filter, SessionSearchQuery query)
|
||||
{
|
||||
if (string.IsNullOrEmpty(q)) return true;
|
||||
|
||||
return ContainsIgnoreCase(session.DeviceName, q)
|
||||
|| ContainsIgnoreCase(session.SerialNumber, q)
|
||||
|| ContainsIgnoreCase(session.Manufacturer, q)
|
||||
|| ContainsIgnoreCase(session.Model, q)
|
||||
|| ContainsIgnoreCase(session.Status.ToString(), q)
|
||||
|| ContainsIgnoreCase(session.SessionId, q)
|
||||
|| ContainsIgnoreCase(session.GeoCountry, q)
|
||||
|| ContainsIgnoreCase(session.GeoRegion, q)
|
||||
|| ContainsIgnoreCase(session.GeoCity, q)
|
||||
|| ContainsIgnoreCase(session.AgentVersion, q)
|
||||
|| ContainsIgnoreCase(session.OsName, q)
|
||||
|| ContainsIgnoreCase(session.OsBuild, q)
|
||||
|| ContainsIgnoreCase(session.OsDisplayVersion, q)
|
||||
|| ContainsIgnoreCase(session.OsEdition, q)
|
||||
|| ContainsIgnoreCase(session.OsLanguage, q);
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(string? haystack, string needle)
|
||||
=> !string.IsNullOrEmpty(haystack)
|
||||
&& haystack!.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
private static bool MatchesScanClientFilters(SessionSummary session, SessionSearchFilter filter)
|
||||
{
|
||||
if (!MatchesFreeText(session, filter.Q)) return false;
|
||||
if (!query.Matches(session)) return false;
|
||||
if (!string.IsNullOrEmpty(filter.SerialNumber) &&
|
||||
!string.Equals(session.SerialNumber, filter.SerialNumber, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
@@ -1074,6 +1056,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private List<SessionSummary> ApplyBasicFilters(List<SessionSummary> sessions, SessionSearchFilter filter)
|
||||
{
|
||||
var query = SessionSearchQuery.Parse(filter.Q);
|
||||
return sessions.Where(s =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(filter.Status) && s.Status.ToString() != filter.Status) return false;
|
||||
@@ -1106,7 +1089,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// query can't return non-matching sessions.
|
||||
if (!MatchesRebootCountBounds(s, filter)) return false;
|
||||
if (!MatchesConnectionType(s, filter)) return false;
|
||||
if (!MatchesFreeText(s, filter.Q)) return false;
|
||||
if (!query.Matches(s)) return false;
|
||||
return true;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -1091,7 +1091,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a single presence row (PK=tenantId, RK=hash(UPN)) stamped with LastSeen=now.
|
||||
/// One row per user — overwritten on every call, so the table never grows past the distinct-user count.
|
||||
/// One row per user, so the table never grows past the distinct-user count.
|
||||
/// </summary>
|
||||
public async Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
|
||||
{
|
||||
@@ -1105,7 +1105,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["LastSeen"] = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
|
||||
// Merge preserves the What's-new seen marks carried on the same row
|
||||
// (WhatsNewSeenPlatformUtc / WhatsNewSeenAgentUtc); a Replace upsert every
|
||||
// ≥60s would wipe them. Upn/UserRole/LastSeen are always set here, so Merge
|
||||
// is semantically identical for presence.
|
||||
await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Merge);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1114,6 +1118,89 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence);
|
||||
var entity = (await tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId,
|
||||
PresenceRowKey(upn),
|
||||
select: new[] { WhatsNewSeenPlatformColumn, WhatsNewSeenAgentColumn })).Value;
|
||||
|
||||
return new UserWhatsNewSeen(
|
||||
entity.GetDateTime(WhatsNewSeenPlatformColumn),
|
||||
entity.GetDateTime(WhatsNewSeenAgentColumn));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return new UserWhatsNewSeen();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read What's-new seen marks for {Upn} in tenant {TenantId}", upn, tenantId);
|
||||
return new UserWhatsNewSeen();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc)
|
||||
{
|
||||
var column = channel.ToLowerInvariant() switch
|
||||
{
|
||||
"platform" => WhatsNewSeenPlatformColumn,
|
||||
"agent" => WhatsNewSeenAgentColumn,
|
||||
_ => throw new ArgumentException("Unknown What's-new channel.", nameof(channel))
|
||||
};
|
||||
|
||||
seenUtc = NormalizeTableUtc(seenUtc);
|
||||
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence);
|
||||
var rowKey = PresenceRowKey(upn);
|
||||
DateTime? existing = null;
|
||||
|
||||
try
|
||||
{
|
||||
var entity = (await tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId,
|
||||
rowKey,
|
||||
select: new[] { column })).Value;
|
||||
existing = entity.GetDateTime(column);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Missing presence is fine: this endpoint may be the first thing that writes the row.
|
||||
}
|
||||
|
||||
if (existing.HasValue && NormalizeTableUtc(existing.Value) >= seenUtc)
|
||||
return;
|
||||
|
||||
var patch = new TableEntity(tenantId, rowKey)
|
||||
{
|
||||
["Upn"] = upn ?? string.Empty,
|
||||
[column] = seenUtc
|
||||
};
|
||||
|
||||
await tableClient.UpsertEntityAsync(patch, TableUpdateMode.Merge);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to mark What's-new {Channel} as seen for {Upn} in tenant {TenantId}", channel, upn, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private const string WhatsNewSeenPlatformColumn = "WhatsNewSeenPlatformUtc";
|
||||
private const string WhatsNewSeenAgentColumn = "WhatsNewSeenAgentUtc";
|
||||
|
||||
private static DateTime NormalizeTableUtc(DateTime value)
|
||||
=> value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns all users whose LastSeen is within the given window (cross-tenant), newest first.
|
||||
/// Intentionally NOT wrapped in a swallow-and-return-empty try/catch: a storage failure must
|
||||
|
||||
@@ -233,6 +233,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["ActivePhasesJson"] = JsonConvert.SerializeObject(rule.ActivePhases ?? new List<string>()),
|
||||
["ActiveFromPhase"] = rule.ActiveFromPhase ?? string.Empty,
|
||||
["EmitMode"] = rule.EmitMode ?? string.Empty,
|
||||
["EnrichErrorCodes"] = rule.EnrichErrorCodes,
|
||||
["OutputEventType"] = rule.OutputEventType ?? string.Empty,
|
||||
["OutputSeverity"] = rule.OutputSeverity ?? "Info",
|
||||
["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]),
|
||||
@@ -343,6 +344,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
ActivePhases = MapActivePhases(entity.GetString("ActivePhasesJson")),
|
||||
ActiveFromPhase = string.IsNullOrEmpty(entity.GetString("ActiveFromPhase")) ? null : entity.GetString("ActiveFromPhase"),
|
||||
EmitMode = string.IsNullOrEmpty(entity.GetString("EmitMode")) ? null : entity.GetString("EmitMode"),
|
||||
// Absent column (pre-existing rows) → false: the opt-in never turns itself on.
|
||||
EnrichErrorCodes = entity.GetBoolean("EnrichErrorCodes") ?? false,
|
||||
OutputEventType = entity.GetString("OutputEventType") ?? string.Empty,
|
||||
OutputSeverity = entity.GetString("OutputSeverity") ?? "Info",
|
||||
Tags = DeserializeJsonArray(entity.GetString("TagsJson")),
|
||||
|
||||
@@ -83,7 +83,14 @@ describe('2026-07-28 (modern) client', () => {
|
||||
try {
|
||||
expect(client.getInstructions()).toContain('as a Global Admin you additionally have tenant-configuration write tools');
|
||||
expect(client.getInstructions()).toContain('omit tenantId for cross-tenant queries');
|
||||
expect(client.getServerVersion()).toEqual({ name: 'Autopilot-Monitor', version: '0.0.0-test' });
|
||||
// Hosts (VS Code) render description/websiteUrl in their server list —
|
||||
// without them the server shows up as "No description provided".
|
||||
expect(client.getServerVersion()).toEqual({
|
||||
name: 'Autopilot-Monitor',
|
||||
version: '0.0.0-test',
|
||||
description: expect.stringContaining('troubleshooting for Windows Autopilot enrollments'),
|
||||
websiteUrl: 'https://www.autopilotmonitor.com',
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -145,4 +145,24 @@ describe('keyEventErrorCode (get_session_summary)', () => {
|
||||
it('surfaces an unknown code without text', () => {
|
||||
expect(keyEventErrorCode({ errorCode: '0xDEADBEEF' })).toEqual({ errorCode: '0xDEADBEEF' });
|
||||
});
|
||||
|
||||
it('never explains gather-rule codes by default — they live in the vendor\'s own numbering (HPiA, DCU, …)', () => {
|
||||
// exitCode 1603 is a well-known MSI code, but from a gather rule it means whatever the parsed
|
||||
// log says; neither the local catalog nor a stale backend sibling may attach text.
|
||||
expect(keyEventErrorCode({ exitCode: '1603' }, 'GatherRuleExecutor')).toEqual({ errorCode: '1603' });
|
||||
expect(keyEventErrorCode({
|
||||
exitCode: '1603',
|
||||
exitCodeInfo: { description: 'A fatal error occurred during installation', symbol: 'ERROR_INSTALL_FAILURE' },
|
||||
}, 'gatherruleexecutor')).toEqual({ errorCode: '1603' });
|
||||
expect(keyEventErrorCode({ exitCode: '1603', enrichErrorCodes: false }, 'GatherRuleExecutor')).toEqual({ errorCode: '1603' });
|
||||
expect(keyEventErrorCode({ exitCode: '1603' }, 'ImeLogMonitor').errorText).toMatch(/^ERROR_INSTALL_FAILURE — /);
|
||||
});
|
||||
|
||||
it('explains gather-rule codes when the rule opted in (agent-stamped enrichErrorCodes marker)', () => {
|
||||
expect(keyEventErrorCode({ exitCode: '1603', enrichErrorCodes: true }, 'GatherRuleExecutor').errorText)
|
||||
.toMatch(/^ERROR_INSTALL_FAILURE — /);
|
||||
// Marker as a string after a DataJson roundtrip.
|
||||
expect(keyEventErrorCode({ exitCode: '1603', enrichErrorCodes: 'true' }, 'GatherRuleExecutor').errorText)
|
||||
.toMatch(/^ERROR_INSTALL_FAILURE — /);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,9 @@ export const API_BASE_URL = resolveApiBaseUrl();
|
||||
/** Entra ID login/token authority host (no trailing slash). */
|
||||
export const ENTRA_LOGIN_BASE_URL = 'https://login.microsoftonline.com';
|
||||
|
||||
/** The product homepage — advertised as serverInfo.websiteUrl. */
|
||||
export const WEBSITE_BASE_URL = 'https://www.autopilotmonitor.com';
|
||||
|
||||
/** Published customer documentation — base for citable search_docs links. */
|
||||
export const DOCS_BASE_URL = 'https://docs.autopilotmonitor.com';
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* mcp live in-process build info (this process IS the live deployment)
|
||||
* mcp manifest versions/mcp.json → … + docsCommit
|
||||
* web live GET {PORTAL}/version.json → { component, commit, buildUtc }
|
||||
* web manifest versions/web.json → { component, commit, deployedUtc, runId }
|
||||
* web manifest versions/web.json → { component, commit, deployedUtc, runId, docsCommit }
|
||||
* (docsCommit = the docs revision the live What's new panel was generated from)
|
||||
* agent manifest {AGENT}/version.json → { version, commit, buildUtc, sha256, bootstrapVersion }
|
||||
* (no live endpoint — the agent runs on devices)
|
||||
*
|
||||
|
||||
@@ -758,6 +758,10 @@ export interface AuthMeResponse {
|
||||
homedApp: string;
|
||||
bootstrapTokenEnabled: boolean;
|
||||
unrestrictedModeEnabled: boolean;
|
||||
/** When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). */
|
||||
whatsNewSeenPlatformUtc?: string;
|
||||
/** Same for the Agent tab; null = never and the key is omitted. */
|
||||
whatsNewSeenAgentUtc?: string;
|
||||
}
|
||||
|
||||
export interface AutoResolveCpeMappingItem {
|
||||
@@ -1206,6 +1210,7 @@ export interface CveExposureSummary {
|
||||
distinctCves: number;
|
||||
kevCves: number;
|
||||
severityBreakdown: SeverityBreakdown;
|
||||
priorityBreakdown: PriorityBreakdown;
|
||||
topCves: TopCve[];
|
||||
truncated: boolean;
|
||||
}
|
||||
@@ -1739,8 +1744,11 @@ export interface FleetDailyPoint {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** Same bucket shape as FleetModelHealth. */
|
||||
export interface FleetFailingModel {
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
label: string;
|
||||
failed: number;
|
||||
/** All sessions on this model in the window, including in-flight ones. */
|
||||
total: number;
|
||||
@@ -1788,8 +1796,11 @@ export interface FleetHealthStats {
|
||||
p90DurationMinutes: number;
|
||||
}
|
||||
|
||||
/** One device-model bucket. Sessions are grouped by (Manufacturer, Model) as stored on the session; the two fields travel separately so a client can address them as the two session fields they are (the dashboard search's manufacturer=… model=…), and Label is the display key — "{Manufacturer} {Model}" trimmed, or "Unknown" when both are blank. */
|
||||
export interface FleetModelHealth {
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
label: string;
|
||||
/** All sessions on this model in the window, including in-flight ones. */
|
||||
total: number;
|
||||
succeeded: number;
|
||||
@@ -1797,8 +1808,11 @@ export interface FleetModelHealth {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/** Same bucket shape as FleetModelHealth. */
|
||||
export interface FleetSlowModel {
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
label: string;
|
||||
avgMinutes: number;
|
||||
count: number;
|
||||
}
|
||||
@@ -1845,6 +1859,8 @@ export interface GatherRule {
|
||||
activeFromPhase?: string | null;
|
||||
/** Emit behavior for collected results: null / "always" = emit on every collection (legacy behavior); "on_change" = poll on the trigger cadence but emit only when the collected result differs from the last emitted one. The first in-scope result always emits; the suppressed poll count is carried on the next emitted event (suppressedPolls / suppressedSinceUtc in the event data). */
|
||||
emitMode?: string | null;
|
||||
/** Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, …) with the Windows/MSI/Intune catalog, i.e. attach the *Info siblings the built-in events get. Default false — gather-rule payload keys are author-chosen and the codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be confidently wrong. Set to true only when the parsed source really uses Windows codes (e.g. an msiexec or IME log). The agent stamps enrichErrorCodes: true into the event data so the read-time enricher can honour it without knowing the rule. */
|
||||
enrichErrorCodes: boolean;
|
||||
/** EventType for the emitted event (e.g., "gather_proxy_settings") */
|
||||
outputEventType: string;
|
||||
/** Severity for the emitted event Default: "Info" */
|
||||
@@ -2326,6 +2342,7 @@ export interface HardwareRejectedResponse {
|
||||
export interface HealthCheck {
|
||||
name: string;
|
||||
description: string;
|
||||
/** One of: "healthy" (working), "warning" (degraded but reachable), "unhealthy" (failing), "unknown" (not determined), "warming" (health/mcp only — the scale-to-zero MCP container did not answer inside the probe budget and is still starting; the caller should re-check, and it must NOT be rated as a fault in any aggregate or overall banner). */
|
||||
status: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
@@ -3078,6 +3095,13 @@ export interface PreviewWhitelistTenantEntry {
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/** Distinct-CVE counts grouped by their highest remediation priority (act/attend/track). Rows written before priorities existed carry no band and are counted in none - the three add up to at most DistinctCves. act equals the KEV set by construction (CvePriority.Compute: KEV ⇒ act). */
|
||||
export interface PriorityBreakdown {
|
||||
act: number;
|
||||
attend: number;
|
||||
track: number;
|
||||
}
|
||||
|
||||
/** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */
|
||||
export interface ProgressGetSessionEventsResponse {
|
||||
success: boolean;
|
||||
@@ -4581,7 +4605,7 @@ export interface TenantConfiguration {
|
||||
rebootDelaySeconds?: number | null;
|
||||
/** Whether to enable geo-location detection (queries external IP service). null = use agent default (true). */
|
||||
enableGeoLocation?: boolean | null;
|
||||
/** NTP server address for time check during enrollment. null = use agent default ("time.windows.com"). */
|
||||
/** NTP server address for time check during enrollment. The initializer is the same value the table read maps a blank cell to and the agent falls back to, so a fresh configuration, a stored one and the manifest default agree (D-216). */
|
||||
ntpServer: string;
|
||||
/** Whether to automatically set the device timezone based on IP geolocation. Requires EnableGeoLocation to be true. Uses tzutil /s to apply. null = use agent default (false). */
|
||||
enableTimezoneAutoSet?: boolean | null;
|
||||
@@ -5230,3 +5254,11 @@ export interface VerdictCalibrationTrendWindow {
|
||||
sessions: number;
|
||||
sharePct: number;
|
||||
}
|
||||
|
||||
/** Body of PUT auth/me/whats-new-seen. */
|
||||
export interface WhatsNewSeenRequest {
|
||||
/** What's-new channel: "platform" or "agent". */
|
||||
channel: string;
|
||||
/** UTC timestamp when the caller viewed the channel. */
|
||||
seenUtc: string;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,20 @@ import { registerResources } from './resources.js';
|
||||
import { registerPrompts } from './prompts.js';
|
||||
import type { DocsSearchBundle, SearchProvider } from './search-provider.js';
|
||||
import { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds, getHomeTenantId } from './client.js';
|
||||
import { WEBSITE_BASE_URL } from './config.js';
|
||||
|
||||
/**
|
||||
* serverInfo metadata (MCP `Implementation`). Hosts such as VS Code render `description`
|
||||
* as a one-liner under the server name — omit it and they show "No description provided".
|
||||
* Convention (spec + servers like Context7): one plain sentence on WHAT the server exposes,
|
||||
* `websiteUrl` = product homepage. Role/safety guidance belongs in `instructions`, not here.
|
||||
*/
|
||||
export const SERVER_INFO = {
|
||||
name: 'Autopilot-Monitor',
|
||||
description:
|
||||
'Autopilot Monitor provides troubleshooting for Windows Autopilot enrollments, including session timelines, analyze rules, error codes, and fleet analytics.',
|
||||
websiteUrl: WEBSITE_BASE_URL,
|
||||
} as const;
|
||||
|
||||
export interface ServerDeps {
|
||||
/** Advertised in serverInfo (initialize / discover / every 2026 result `_meta`) and on /health. */
|
||||
@@ -88,7 +102,7 @@ const RESOURCE_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
*/
|
||||
export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean, delegated: boolean, managedTenants: string[], homeTenantId?: string): McpServer {
|
||||
const s = new McpServer(
|
||||
{ name: 'Autopilot-Monitor', version: deps.serverVersion },
|
||||
{ ...SERVER_INFO, version: deps.serverVersion },
|
||||
{
|
||||
// Delivered in `initialize` (2025 clients) and `server/discover` (2026-07-28 clients).
|
||||
instructions: buildInstructions(deps, ga, strictGa, delegated, managedTenants, homeTenantId),
|
||||
|
||||
@@ -107,6 +107,13 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
'Optional activePhases (array) or activeFromPhase restrict when the rule may run at all; ' +
|
||||
'emitMode "on_change" emits only when the collected payload differs from the previous run ' +
|
||||
'(default "always").',
|
||||
errorCodes:
|
||||
'Gather-rule output is NOT run through the Windows/MSI/Intune error-code catalog by default: ' +
|
||||
'a captured exitCode/errorCode/hresult from a third-party log (HP Image Assistant, Dell ' +
|
||||
'Command Update, custom scripts) uses the vendor\'s own numbering, and a catalog meaning would ' +
|
||||
'be confidently wrong. Set enrichErrorCodes: true only when the parsed source really uses ' +
|
||||
'Windows codes (e.g. an msiexec or IME log); the backend then attaches the *Info siblings ' +
|
||||
'(exitCodeInfo, errorCodeInfo, ...) exactly like on built-in events.',
|
||||
output:
|
||||
'outputEventType names the emitted event; use the gather_ prefix convention (e.g. ' +
|
||||
'"gather_pending_reboot") so analyze rules and humans can tell collected events from ' +
|
||||
|
||||
@@ -178,6 +178,11 @@ export const GATHER_RULE_SCHEMA: Record<string, unknown> = {
|
||||
"on_change"
|
||||
]
|
||||
},
|
||||
"enrichErrorCodes": {
|
||||
"type": "boolean",
|
||||
"description": "Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, ...) with the Windows/MSI/Intune error-code catalog, attaching the same *Info siblings built-in events get. Default false because gather-rule codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be wrong. Enable only when the parsed source really uses Windows codes (e.g. an msiexec or IME log).",
|
||||
"default": false
|
||||
},
|
||||
"outputEventType": {
|
||||
"type": "string",
|
||||
"description": "EventType for the emitted event (e.g., \"gather_proxy_settings\")."
|
||||
|
||||
@@ -1823,7 +1823,8 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
title: 'Vulnerability Summary',
|
||||
description:
|
||||
'Get a fleet-wide vulnerability exposure summary aggregated from detected CVEs: total affected devices, ' +
|
||||
'distinct CVE count, KEV (CISA Known-Exploited) count, a severity breakdown, and the top CVEs ranked by how ' +
|
||||
'distinct CVE count, KEV (CISA Known-Exploited) count, a severity breakdown, a priority breakdown ' +
|
||||
'(act/attend/track counts over every CVE in the window), and the top CVEs ranked by how ' +
|
||||
'many devices they affect. Each top CVE carries cvssScore/cvssSeverity, isKev, epssScore (FIRST EPSS ' +
|
||||
'probability of exploitation within 30 days, 0-1, null when unscored) and priority ("act" = in CISA KEV, ' +
|
||||
'"attend" = EPSS >= 0.1 or CVSS >= 9.0, "track" = everything else) — rank remediation by priority, then EPSS, ' +
|
||||
|
||||
@@ -634,7 +634,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
phase: phaseName(e.phase, s.enrollmentType),
|
||||
message: e.message,
|
||||
source: e.source,
|
||||
...keyEventErrorCode(e.data),
|
||||
...keyEventErrorCode(e.data, e.source),
|
||||
}));
|
||||
|
||||
let analysis = null;
|
||||
@@ -906,18 +906,40 @@ const ERROR_CODE_KEYS: ReadonlyArray<[code: string, info: string]> = [
|
||||
['hresultFromWin32', 'hresultFromWin32Info'],
|
||||
];
|
||||
|
||||
/**
|
||||
* `source` the agent stamps on gather-rule output. Those payload keys are author-chosen and the
|
||||
* codes come from third-party logs/commands (HP Image Assistant, Dell Command Update, custom
|
||||
* scripts) with their own numbering, so neither the backend enricher nor the local catalog
|
||||
* fallback may explain them unless the rule opted in via `enrichErrorCodes` (the agent then
|
||||
* stamps `enrichErrorCodes: true` into the data). The raw code always travels.
|
||||
*/
|
||||
const GATHER_RULE_SOURCE = 'GatherRuleExecutor';
|
||||
const ENRICH_OPT_IN_KEY = 'enrichErrorCodes';
|
||||
|
||||
function isOptedOutGatherEvent(data: Record<string, unknown>, source: string | null | undefined): boolean {
|
||||
if (typeof source !== 'string' || source.toLowerCase() !== GATHER_RULE_SOURCE.toLowerCase()) return false;
|
||||
const marker = data[ENRICH_OPT_IN_KEY];
|
||||
return !(marker === true || (typeof marker === 'string' && marker.toLowerCase() === 'true'));
|
||||
}
|
||||
|
||||
/**
|
||||
* `errorCode` + `errorText` for a key event, only when the payload carries a code. The text
|
||||
* comes from the backend-enriched `*Info` sibling (symbol + catalog meaning); an older
|
||||
* response without the sibling falls back to the server's own catalog. A code the catalog
|
||||
* does not know is still surfaced as errorCode, without errorText.
|
||||
* does not know is still surfaced as errorCode, without errorText. Gather-rule events
|
||||
* (`source === GatherRuleExecutor`) get catalog text only when their rule opted in.
|
||||
*/
|
||||
export function keyEventErrorCode(data: Record<string, unknown> | undefined): { errorCode?: string; errorText?: string } {
|
||||
export function keyEventErrorCode(
|
||||
data: Record<string, unknown> | undefined,
|
||||
source?: string | null,
|
||||
): { errorCode?: string; errorText?: string } {
|
||||
if (!data) return {};
|
||||
const suppressCatalogText = isOptedOutGatherEvent(data, source);
|
||||
for (const [codeKey, infoKey] of ERROR_CODE_KEYS) {
|
||||
const raw = data[codeKey];
|
||||
if (raw === undefined || raw === null || raw === '' || raw === 0 || raw === '0') continue;
|
||||
const errorCode = String(raw);
|
||||
if (suppressCatalogText) return { errorCode };
|
||||
const info = data[infoKey] as { description?: unknown; symbol?: unknown } | undefined;
|
||||
let symbol: string | undefined;
|
||||
let description: string | undefined;
|
||||
|
||||
@@ -823,6 +823,27 @@ namespace AutopilotMonitor.Shared
|
||||
public const string Registry = "Registry";
|
||||
public const string WMI = "WMI";
|
||||
public const string Network = "Network";
|
||||
|
||||
/// <summary>
|
||||
/// Stamped on every event the agent's gather-rule pipeline emits (executor, guard
|
||||
/// events, collectors). Backend and MCP key off it to treat the payload as
|
||||
/// author-defined (no error-code enrichment unless <see cref="GatherRuleDataKeys.EnrichErrorCodes"/>).
|
||||
/// </summary>
|
||||
public const string GatherRuleExecutor = "GatherRuleExecutor";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known keys the agent injects into gather-rule event data next to the
|
||||
/// collector's own fields.
|
||||
/// </summary>
|
||||
public static class GatherRuleDataKeys
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c> when the rule opted into <see cref="Models.GatherRule.EnrichErrorCodes"/>;
|
||||
/// absent otherwise. Lets the read-time error-code enricher honour the rule setting
|
||||
/// without knowing the rule.
|
||||
/// </summary>
|
||||
public const string EnrichErrorCodes = "enrichErrorCodes";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -86,6 +86,10 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
// --- Live Presence (one row per user, upserted) ---
|
||||
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
|
||||
Task RecordUserPresenceAsync(string tenantId, string upn, string userRole);
|
||||
/// <summary>Gets the caller's per-channel What's-new seen marks from the presence row; nulls mean never or unavailable.</summary>
|
||||
Task<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn);
|
||||
/// <summary>Monotonically merge-upserts the caller's What's-new seen mark for one channel without touching presence fields.</summary>
|
||||
Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc);
|
||||
/// <summary>Returns all users whose LastSeen falls within the given window, newest first (cross-tenant).</summary>
|
||||
Task<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window);
|
||||
/// <summary>Retention cleanup: deletes presence rows whose LastSeen is older than the cutoff (drops one-off testers). Returns the number deleted.</summary>
|
||||
|
||||
@@ -449,10 +449,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool? EnableGeoLocation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// NTP server address for time check during enrollment.
|
||||
/// null = use agent default ("time.windows.com").
|
||||
/// NTP server address for time check during enrollment. The initializer is the same
|
||||
/// value the table read maps a blank cell to and the agent falls back to, so a fresh
|
||||
/// configuration, a stored one and the manifest default agree (D-216).
|
||||
/// </summary>
|
||||
public string NtpServer { get; set; } = default!;
|
||||
public string NtpServer { get; set; } = "time.windows.com";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically set the device timezone based on IP geolocation.
|
||||
|
||||
@@ -65,9 +65,18 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One device-model bucket. Sessions are grouped by (Manufacturer, Model) as stored on the
|
||||
/// session; the two fields travel separately so a client can address them as the two
|
||||
/// session fields they are (the dashboard search's <c>manufacturer=… model=…</c>), and
|
||||
/// <see cref="Label"/> is the display key — "{Manufacturer} {Model}" trimmed, or "Unknown"
|
||||
/// when both are blank.
|
||||
/// </summary>
|
||||
public sealed class FleetModelHealth
|
||||
{
|
||||
public string Model { get; set; } = default!;
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = default!;
|
||||
/// <summary>All sessions on this model in the window, including in-flight ones.</summary>
|
||||
public int Total { get; set; }
|
||||
public int Succeeded { get; set; }
|
||||
@@ -75,16 +84,22 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int Failed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Same bucket shape as <see cref="FleetModelHealth"/>.</summary>
|
||||
public sealed class FleetSlowModel
|
||||
{
|
||||
public string Model { get; set; } = default!;
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = default!;
|
||||
public int AvgMinutes { get; set; }
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Same bucket shape as <see cref="FleetModelHealth"/>.</summary>
|
||||
public sealed class FleetFailingModel
|
||||
{
|
||||
public string Model { get; set; } = default!;
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = default!;
|
||||
public int Failed { get; set; }
|
||||
/// <summary>All sessions on this model in the window, including in-flight ones.</summary>
|
||||
public int Total { get; set; }
|
||||
|
||||
@@ -37,6 +37,20 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
public bool BootstrapTokenEnabled { get; set; }
|
||||
public bool UnrestrictedModeEnabled { get; set; }
|
||||
|
||||
/// <summary>When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit).</summary>
|
||||
public DateTime? WhatsNewSeenPlatformUtc { get; set; }
|
||||
/// <summary>Same for the Agent tab; null = never and the key is omitted.</summary>
|
||||
public DateTime? WhatsNewSeenAgentUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body of PUT auth/me/whats-new-seen.</summary>
|
||||
public class WhatsNewSeenRequest : IApiRequest
|
||||
{
|
||||
/// <summary>What's-new channel: "platform" or "agent".</summary>
|
||||
public string Channel { get; set; } = default!;
|
||||
/// <summary>UTC timestamp when the caller viewed the channel.</summary>
|
||||
public DateTime SeenUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -137,6 +151,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// One of: "healthy" (working), "warning" (degraded but reachable), "unhealthy"
|
||||
/// (failing), "unknown" (not determined), "warming" (health/mcp only — the
|
||||
/// scale-to-zero MCP container did not answer inside the probe budget and is still
|
||||
/// starting; the caller should re-check, and it must NOT be rated as a fault in any
|
||||
/// aggregate or overall banner).
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "unknown";
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public Dictionary<string, object>? Details { get; set; }
|
||||
|
||||
@@ -165,6 +165,19 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string? EmitMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Opt-in: let the backend explain error-code fields in this rule's output
|
||||
/// (<c>exitCode</c>, <c>errorCode</c>, <c>hresult</c>, …) with the Windows/MSI/Intune
|
||||
/// catalog, i.e. attach the <c>*Info</c> siblings the built-in events get.
|
||||
/// Default false — gather-rule payload keys are author-chosen and the codes usually come
|
||||
/// from third-party logs or commands (HP Image Assistant, Dell Command Update, custom
|
||||
/// scripts) with their own exit-code numbering, where the catalog meaning would be
|
||||
/// confidently wrong. Set to true only when the parsed source really uses Windows codes
|
||||
/// (e.g. an msiexec or IME log). The agent stamps <c>enrichErrorCodes: true</c> into the
|
||||
/// event data so the read-time enricher can honour it without knowing the rule.
|
||||
/// </summary>
|
||||
public bool EnrichErrorCodes { get; set; } = false;
|
||||
|
||||
// ===== OUTPUT =====
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>The caller's per-channel What's-new seen marks from the UserPresence row.</summary>
|
||||
public sealed class UserWhatsNewSeen
|
||||
{
|
||||
public UserWhatsNewSeen(DateTime? platformUtc = null, DateTime? agentUtc = null)
|
||||
{
|
||||
PlatformUtc = platformUtc;
|
||||
AgentUtc = agentUtc;
|
||||
}
|
||||
|
||||
public DateTime? PlatformUtc { get; }
|
||||
public DateTime? AgentUtc { get; }
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int DistinctCves { get; set; }
|
||||
public int KevCves { get; set; }
|
||||
public SeverityBreakdown SeverityBreakdown { get; set; } = default!;
|
||||
public PriorityBreakdown PriorityBreakdown { get; set; } = default!;
|
||||
public IReadOnlyList<TopCve> TopCves { get; set; } = default!;
|
||||
public bool Truncated { get; set; }
|
||||
}
|
||||
@@ -350,6 +351,20 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int Low { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinct-CVE counts grouped by their highest remediation priority (act/attend/track). Rows
|
||||
/// written before priorities existed carry no band and are counted in none - the three add up
|
||||
/// to at most <see cref="CveExposureSummary.DistinctCves"/>. act equals the KEV set by
|
||||
/// construction (CvePriority.Compute: KEV ⇒ act).
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class PriorityBreakdown
|
||||
{
|
||||
public int Act { get; set; }
|
||||
public int Attend { get; set; }
|
||||
public int Track { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single CVE ranked by how many devices it affects in scope. <see cref="EpssScore"/>
|
||||
/// is the highest EPSS probability seen across the rows (null = unscored / legacy rows);
|
||||
|
||||
@@ -394,7 +394,7 @@ export function OpsAlertRulesSection({
|
||||
return (
|
||||
<div key={et}>
|
||||
<div className="flex items-center gap-3 py-1.5 px-3 rounded hover:bg-amber-100/50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<label className="relative inline-flex flex-shrink-0 items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled}
|
||||
@@ -403,14 +403,16 @@ export function OpsAlertRulesSection({
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-amber-500 dark:peer-checked:bg-amber-500 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
|
||||
</label>
|
||||
<span className={`flex-1 text-sm font-mono ${rule.enabled ? "text-gray-900 dark:text-gray-100" : "text-gray-400 dark:text-gray-500"}`}>
|
||||
{/* min-w-0 + break-words: the event type is one long unbreakable token and
|
||||
would otherwise push the severity select off a phone-width viewport. */}
|
||||
<span className={`flex-1 min-w-0 break-words text-sm font-mono ${rule.enabled ? "text-gray-900 dark:text-gray-100" : "text-gray-400 dark:text-gray-500"}`}>
|
||||
{et}
|
||||
</span>
|
||||
<select
|
||||
value={rule.minSeverity}
|
||||
onChange={(e) => setSeverity(et, e.target.value)}
|
||||
disabled={!rule.enabled}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus:ring-1 focus:ring-amber-500"
|
||||
className="flex-shrink-0 text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus:ring-1 focus:ring-amber-500"
|
||||
>
|
||||
{SEVERITIES.map(s => (
|
||||
<option key={s} value={s}>{s}+</option>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import type {
|
||||
AdminConfiguration as WireAdminConfiguration,
|
||||
CveExposureSummary,
|
||||
GetVulnerabilitySyncStatusResponse,
|
||||
TriggerEpssSyncResponse,
|
||||
TriggerMsrcSyncResponse,
|
||||
@@ -18,26 +19,6 @@ import type {
|
||||
UpdateAdminConfigurationResponse,
|
||||
} from "@/utils/wire-types.generated";
|
||||
|
||||
interface VulnerabilityTopCve {
|
||||
cveId: string;
|
||||
cvssSeverity: string;
|
||||
cvssScore: number;
|
||||
isKev: boolean;
|
||||
affectedSessions: number;
|
||||
sampleSoftware: string[];
|
||||
}
|
||||
|
||||
interface VulnerabilitySummary {
|
||||
windowDays: number;
|
||||
totalAffectedSessions: number;
|
||||
totalAffectedTenants: number | null;
|
||||
distinctCves: number;
|
||||
kevCves: number;
|
||||
severityBreakdown: { critical: number; high: number; medium: number; low: number };
|
||||
topCves: VulnerabilityTopCve[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
interface VulnerabilityDataSectionProps {
|
||||
adminConfig: AdminConfiguration | null;
|
||||
loadingConfig: boolean;
|
||||
@@ -94,7 +75,7 @@ export function VulnerabilityDataSection({
|
||||
} | null>(null);
|
||||
|
||||
// Fleet exposure overview (read-only; cross-tenant via /api/global/metrics/vulnerability)
|
||||
const [summary, setSummary] = useState<VulnerabilitySummary | null>(null);
|
||||
const [summary, setSummary] = useState<CveExposureSummary | null>(null);
|
||||
const [summaryDays, setSummaryDays] = useState(30);
|
||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
||||
|
||||
@@ -144,7 +125,7 @@ export function VulnerabilityDataSection({
|
||||
const refreshSummary = useCallback(async () => {
|
||||
setLoadingSummary(true);
|
||||
try {
|
||||
setSummary(await fetchJson<VulnerabilitySummary>(api.metrics.globalVulnerability(summaryDays, 10), getAccessToken));
|
||||
setSummary(await fetchJson<CveExposureSummary>(api.metrics.globalVulnerability(summaryDays, 10), getAccessToken));
|
||||
} catch {
|
||||
// Non-critical: the overview is auxiliary; config + sync still render.
|
||||
setSummary(null);
|
||||
|
||||
@@ -306,7 +306,7 @@ export function SectionAgentMetrics() {
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-normal text-gray-900">Agent Metrics</h1>
|
||||
{cacheInfo && (
|
||||
@@ -317,7 +317,7 @@ export function SectionAgentMetrics() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<label className="text-sm text-gray-500">Window:</label>
|
||||
<select
|
||||
value={windowDays}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function SectionPlatformUsage() {
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-normal text-gray-900">Platform Usage Metrics</h1>
|
||||
@@ -148,7 +148,7 @@ export function SectionPlatformUsage() {
|
||||
<button
|
||||
onClick={() => fetchMetrics(true)}
|
||||
disabled={refreshing}
|
||||
className="px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
className="self-start sm:self-auto px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<svg className={`h-5 w-5 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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" />
|
||||
|
||||
+225
-464
@@ -4,6 +4,19 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import { useAuth } from '../../../../contexts/AuthContext';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
import type {
|
||||
AgentConfigResponse,
|
||||
TenantConfiguration,
|
||||
TenantFeatureFlagsResponse,
|
||||
} from "@/utils/wire-types.generated";
|
||||
import {
|
||||
isNonDefault,
|
||||
RUNTIME_REPORT_SECTIONS,
|
||||
RUNTIME_SECTIONS_COLLAPSED,
|
||||
TENANT_REPORT_SECTIONS,
|
||||
type RowKind,
|
||||
type RuntimeSource,
|
||||
} from './tenantConfigReportCatalog';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,201 +25,6 @@ interface TenantInfo {
|
||||
domainName: string;
|
||||
}
|
||||
|
||||
/** Full tenant configuration as returned by GET /api/config/{tenantId}. */
|
||||
interface TenantConfig {
|
||||
tenantId: string;
|
||||
domainName?: string;
|
||||
lastUpdated?: string;
|
||||
updatedBy?: string;
|
||||
onboardedAt?: string;
|
||||
contactEmail?: string | null;
|
||||
companyName?: string | null;
|
||||
|
||||
// Tenant status
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
disabledUntil?: string;
|
||||
mcpDisabled?: boolean;
|
||||
mcpDisabledReason?: string;
|
||||
|
||||
// Security
|
||||
customRateLimitRequestsPerMinute?: number | null;
|
||||
customUserRateLimitRequestsPerMinute?: number | null;
|
||||
manufacturerWhitelist?: string;
|
||||
modelWhitelist?: string;
|
||||
validateAutopilotDevice?: boolean;
|
||||
validateCorporateIdentifier?: boolean;
|
||||
allowInsecureAgentRequests?: boolean;
|
||||
|
||||
// Data management
|
||||
dataRetentionDays?: number;
|
||||
sessionTimeoutHours?: number;
|
||||
maxNdjsonPayloadSizeMB?: number;
|
||||
|
||||
// Collectors
|
||||
enablePerformanceCollector?: boolean;
|
||||
performanceCollectorIntervalSeconds?: number;
|
||||
helloWaitTimeoutSeconds?: number;
|
||||
|
||||
// Auth circuit breaker
|
||||
maxAuthFailures?: number | null;
|
||||
authFailureTimeoutMinutes?: number | null;
|
||||
agentMaxLifetimeMinutes?: number | null;
|
||||
|
||||
// Agent behavior
|
||||
selfDestructOnComplete?: boolean | null;
|
||||
keepLogFile?: boolean | null;
|
||||
rebootOnComplete?: boolean | null;
|
||||
rebootDelaySeconds?: number | null;
|
||||
enableGeoLocation?: boolean | null;
|
||||
enableTimezoneAutoSet?: boolean | null;
|
||||
enableDoGroupIdAutoSet?: boolean | null;
|
||||
ntpServer?: string | null;
|
||||
enableImeMatchLog?: boolean | null;
|
||||
enableGatherRuleDebugLog?: boolean | null;
|
||||
enableEspContinueAnywayObservation?: boolean | null;
|
||||
logLevel?: string | null;
|
||||
maxBatchSize?: number | null;
|
||||
showEnrollmentSummary?: boolean | null;
|
||||
enrollmentSummaryTimeoutSeconds?: number | null;
|
||||
enrollmentSummaryBrandingImageUrl?: string | null;
|
||||
enrollmentSummaryLaunchRetrySeconds?: number | null;
|
||||
showScriptOutput?: boolean | null;
|
||||
sendTraceEvents?: boolean;
|
||||
|
||||
// Analyzers
|
||||
enableLocalAdminAnalyzer?: boolean | null;
|
||||
enableSoftwareInventoryAnalyzer?: boolean | null;
|
||||
localAdminAllowedAccountsJson?: string | null;
|
||||
|
||||
// Plan tier
|
||||
planTier?: string;
|
||||
trialExpiresUtc?: string | null;
|
||||
trialConsumed?: boolean;
|
||||
|
||||
// Bootstrap & unrestricted
|
||||
bootstrapTokenEnabled?: boolean;
|
||||
unrestrictedModeEnabled?: boolean;
|
||||
unrestrictedMode?: boolean;
|
||||
|
||||
// Diagnostics
|
||||
diagnosticsBlobSasUrl?: string | null;
|
||||
diagnosticsUploadMode?: string;
|
||||
diagnosticsLogPathsJson?: string | null;
|
||||
|
||||
// Webhook notifications (new)
|
||||
webhookProviderType?: number;
|
||||
webhookUrl?: string | null;
|
||||
webhookNotifyOnSuccess?: boolean;
|
||||
webhookNotifyOnFailure?: boolean;
|
||||
webhookNotifyOnStart?: boolean;
|
||||
notificationChannelsJson?: string | null;
|
||||
|
||||
// Teams notifications (legacy)
|
||||
teamsWebhookUrl?: string | null;
|
||||
teamsNotifyOnSuccess?: boolean;
|
||||
teamsNotifyOnFailure?: boolean;
|
||||
teamsNotifyOnStart?: boolean;
|
||||
}
|
||||
|
||||
// ── Defaults (mirrors C# TenantConfiguration defaults) ──────────────────────
|
||||
|
||||
const DEFAULTS: Record<string, unknown> = {
|
||||
disabled: false,
|
||||
mcpDisabled: false,
|
||||
customRateLimitRequestsPerMinute: null,
|
||||
customUserRateLimitRequestsPerMinute: null,
|
||||
manufacturerWhitelist: 'Dell*,HP*,Lenovo*,Microsoft Corporation',
|
||||
modelWhitelist: '*',
|
||||
validateAutopilotDevice: false,
|
||||
validateCorporateIdentifier: false,
|
||||
allowInsecureAgentRequests: false,
|
||||
dataRetentionDays: 90,
|
||||
sessionTimeoutHours: 5,
|
||||
maxNdjsonPayloadSizeMB: 5,
|
||||
enablePerformanceCollector: true,
|
||||
performanceCollectorIntervalSeconds: 30,
|
||||
helloWaitTimeoutSeconds: 30,
|
||||
maxAuthFailures: null,
|
||||
authFailureTimeoutMinutes: null,
|
||||
agentMaxLifetimeMinutes: null,
|
||||
selfDestructOnComplete: true,
|
||||
keepLogFile: false,
|
||||
rebootOnComplete: null,
|
||||
rebootDelaySeconds: null,
|
||||
enableGeoLocation: null,
|
||||
enableTimezoneAutoSet: null,
|
||||
enableDoGroupIdAutoSet: null,
|
||||
ntpServer: 'time.windows.com',
|
||||
enableImeMatchLog: null,
|
||||
enableGatherRuleDebugLog: null,
|
||||
enableEspContinueAnywayObservation: null,
|
||||
logLevel: null,
|
||||
maxBatchSize: null,
|
||||
showEnrollmentSummary: null,
|
||||
enrollmentSummaryTimeoutSeconds: null,
|
||||
enrollmentSummaryBrandingImageUrl: null,
|
||||
enrollmentSummaryLaunchRetrySeconds: null,
|
||||
showScriptOutput: true,
|
||||
sendTraceEvents: true,
|
||||
enableLocalAdminAnalyzer: null,
|
||||
enableSoftwareInventoryAnalyzer: null,
|
||||
localAdminAllowedAccountsJson: null,
|
||||
planTier: 'free',
|
||||
bootstrapTokenEnabled: false,
|
||||
unrestrictedModeEnabled: false,
|
||||
unrestrictedMode: false,
|
||||
diagnosticsBlobSasUrl: null,
|
||||
diagnosticsUploadMode: 'Off',
|
||||
diagnosticsLogPathsJson: null,
|
||||
webhookProviderType: 0,
|
||||
webhookUrl: null,
|
||||
webhookNotifyOnSuccess: true,
|
||||
webhookNotifyOnFailure: true,
|
||||
webhookNotifyOnStart: false,
|
||||
teamsWebhookUrl: null,
|
||||
teamsNotifyOnSuccess: true,
|
||||
teamsNotifyOnFailure: true,
|
||||
teamsNotifyOnStart: false,
|
||||
};
|
||||
|
||||
// Runtime defaults (what the agent actually receives if tenant didn't set a value)
|
||||
const RUNTIME_DEFAULTS: Record<string, unknown> = {
|
||||
uploadIntervalSeconds: 30,
|
||||
selfDestructOnComplete: true,
|
||||
keepLogFile: false,
|
||||
enableGeoLocation: true,
|
||||
enableTimezoneAutoSet: false,
|
||||
enableDoGroupIdAutoSet: false,
|
||||
ntpServer: 'time.windows.com',
|
||||
enableImeMatchLog: false,
|
||||
enableGatherRuleDebugLog: false,
|
||||
enableEspContinueAnywayObservation: false,
|
||||
maxAuthFailures: 5,
|
||||
authFailureTimeoutMinutes: 0,
|
||||
logLevel: 'Info',
|
||||
rebootOnComplete: false,
|
||||
rebootDelaySeconds: 10,
|
||||
showEnrollmentSummary: false,
|
||||
enrollmentSummaryTimeoutSeconds: 60,
|
||||
enrollmentSummaryLaunchRetrySeconds: 120,
|
||||
maxBatchSize: 100,
|
||||
diagnosticsUploadMode: 'Off',
|
||||
sendTraceEvents: true,
|
||||
unrestrictedMode: false,
|
||||
// Collectors
|
||||
enablePerformanceCollector: true,
|
||||
performanceIntervalSeconds: 30,
|
||||
collectorIdleTimeoutMinutes: 15,
|
||||
enableAgentSelfMetrics: true,
|
||||
agentSelfMetricsIntervalSeconds: 60,
|
||||
helloWaitTimeoutSeconds: 30,
|
||||
agentMaxLifetimeMinutes: 360,
|
||||
// Analyzers
|
||||
enableLocalAdminAnalyzer: true,
|
||||
enableSoftwareInventoryAnalyzer: false,
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const WEBHOOK_PROVIDERS: Record<number, string> = {
|
||||
@@ -218,21 +36,21 @@ const WEBHOOK_PROVIDERS: Record<number, string> = {
|
||||
30: 'Discord',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<RuntimeSource, string> = {
|
||||
tenant: 'tenant',
|
||||
global: 'global',
|
||||
entitlement: 'entitlement',
|
||||
fixed: 'fixed',
|
||||
};
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === null || val === undefined) return '—';
|
||||
if (typeof val === 'boolean') return val ? 'Yes' : 'No';
|
||||
if (typeof val === 'string') return val || '—';
|
||||
if (Array.isArray(val)) return val.length ? val.join(', ') : '—';
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function isNonDefault(key: string, val: unknown, defaults: Record<string, unknown>): boolean {
|
||||
if (!(key in defaults)) return false;
|
||||
const def = defaults[key];
|
||||
// Both null/undefined → same
|
||||
if ((val === null || val === undefined) && (def === null || def === undefined)) return false;
|
||||
return val !== def;
|
||||
}
|
||||
|
||||
function maskSasUrl(url: string | null | undefined): string {
|
||||
if (!url) return '—';
|
||||
try {
|
||||
@@ -252,30 +70,39 @@ function formatDate(val: string | null | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section component ────────────────────────────────────────────────────────
|
||||
function formatByKind(kind: RowKind | undefined, value: unknown): string {
|
||||
switch (kind) {
|
||||
case 'date':
|
||||
return formatDate(value as string);
|
||||
case 'masked':
|
||||
return maskSasUrl(value as string);
|
||||
case 'secret':
|
||||
return value ? 'configured (hidden)' : '—';
|
||||
case 'count':
|
||||
return Array.isArray(value) ? String(value.length) : '—';
|
||||
default:
|
||||
return formatValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Row and section primitives ───────────────────────────────────────────────
|
||||
|
||||
interface ConfigRowProps {
|
||||
label: string;
|
||||
value: unknown;
|
||||
configKey?: string;
|
||||
defaults?: Record<string, unknown>;
|
||||
masked?: boolean;
|
||||
isDate?: boolean;
|
||||
display: string;
|
||||
highlight?: boolean;
|
||||
sourceTag?: string;
|
||||
}
|
||||
|
||||
function ConfigRow({ label, value, configKey, defaults, masked, isDate }: ConfigRowProps) {
|
||||
const highlight = configKey && defaults ? isNonDefault(configKey, value, defaults) : false;
|
||||
const displayVal = masked
|
||||
? maskSasUrl(value as string)
|
||||
: isDate
|
||||
? formatDate(value as string)
|
||||
: formatValue(value);
|
||||
|
||||
function ConfigRow({ label, display, highlight, sourceTag }: ConfigRowProps) {
|
||||
return (
|
||||
<tr className={highlight ? 'bg-purple-50 dark:bg-purple-900/20' : ''}>
|
||||
<td className="py-1.5 px-3 text-sm text-gray-600 dark:text-gray-400 font-medium whitespace-nowrap">{label}</td>
|
||||
<td className="py-1.5 px-3 text-sm text-gray-600 dark:text-gray-400 font-medium whitespace-nowrap">
|
||||
{label}
|
||||
{sourceTag && <span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">{sourceTag}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{displayVal}
|
||||
{display}
|
||||
{highlight && <span className="ml-2 text-xs text-purple-600 dark:text-purple-400 font-sans">(custom)</span>}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -284,62 +111,96 @@ function ConfigRow({ label, value, configKey, defaults, masked, isDate }: Config
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
/** Rendered as a closed <details> — for rows that are rarely relevant (agent class defaults). */
|
||||
collapsed?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ title, children }: SectionProps) {
|
||||
function Section({ title, collapsed, children }: SectionProps) {
|
||||
const heading = <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">{title}</h3>;
|
||||
const table = (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
if (collapsed) {
|
||||
return (
|
||||
<details className="mb-6 group">
|
||||
<summary className="cursor-pointer list-none mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 group-open:rotate-90 transition-transform">▶</span>
|
||||
{heading}
|
||||
</summary>
|
||||
{table}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">{title}</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mb-2">{heading}</div>
|
||||
{table}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Compute runtime parameters ───────────────────────────────────────────────
|
||||
// ── Notification channels (the one non-scalar block) ─────────────────────────
|
||||
|
||||
function computeRuntime(c: TenantConfig): Record<string, unknown> {
|
||||
return {
|
||||
uploadIntervalSeconds: 30,
|
||||
selfDestructOnComplete: c.selfDestructOnComplete ?? true,
|
||||
keepLogFile: c.keepLogFile ?? false,
|
||||
enableGeoLocation: c.enableGeoLocation ?? true,
|
||||
enableTimezoneAutoSet: c.enableTimezoneAutoSet ?? false,
|
||||
enableDoGroupIdAutoSet: c.enableDoGroupIdAutoSet ?? false,
|
||||
ntpServer: c.ntpServer ?? 'time.windows.com',
|
||||
enableImeMatchLog: c.enableImeMatchLog ?? false,
|
||||
enableGatherRuleDebugLog: c.enableGatherRuleDebugLog ?? false,
|
||||
enableEspContinueAnywayObservation: c.enableEspContinueAnywayObservation ?? false,
|
||||
maxAuthFailures: c.maxAuthFailures ?? 5,
|
||||
authFailureTimeoutMinutes: c.authFailureTimeoutMinutes ?? 0,
|
||||
logLevel: c.logLevel ?? 'Info',
|
||||
rebootOnComplete: c.rebootOnComplete ?? false,
|
||||
rebootDelaySeconds: c.rebootDelaySeconds ?? 10,
|
||||
showEnrollmentSummary: c.showEnrollmentSummary ?? false,
|
||||
enrollmentSummaryTimeoutSeconds: c.enrollmentSummaryTimeoutSeconds ?? 60,
|
||||
enrollmentSummaryBrandingImageUrl: c.enrollmentSummaryBrandingImageUrl ?? null,
|
||||
enrollmentSummaryLaunchRetrySeconds: c.enrollmentSummaryLaunchRetrySeconds ?? 120,
|
||||
maxBatchSize: c.maxBatchSize ?? 100,
|
||||
diagnosticsUploadEnabled: !!(c.diagnosticsBlobSasUrl && c.diagnosticsUploadMode && c.diagnosticsUploadMode !== 'Off'),
|
||||
diagnosticsUploadMode: c.diagnosticsUploadMode ?? 'Off',
|
||||
sendTraceEvents: c.sendTraceEvents ?? true,
|
||||
unrestrictedMode: c.unrestrictedMode ?? false,
|
||||
// Collectors
|
||||
enablePerformanceCollector: c.enablePerformanceCollector ?? true,
|
||||
performanceIntervalSeconds: c.performanceCollectorIntervalSeconds ?? 30,
|
||||
collectorIdleTimeoutMinutes: 15,
|
||||
enableAgentSelfMetrics: true,
|
||||
agentSelfMetricsIntervalSeconds: 60,
|
||||
helloWaitTimeoutSeconds: c.helloWaitTimeoutSeconds ?? 30,
|
||||
agentMaxLifetimeMinutes: c.agentMaxLifetimeMinutes ?? 360,
|
||||
// Analyzers
|
||||
enableLocalAdminAnalyzer: c.enableLocalAdminAnalyzer ?? true,
|
||||
enableSoftwareInventoryAnalyzer: c.enableSoftwareInventoryAnalyzer ?? false,
|
||||
};
|
||||
interface ChannelView {
|
||||
id: string;
|
||||
name?: string;
|
||||
providerType?: number;
|
||||
url?: string;
|
||||
enabled?: boolean;
|
||||
notifyOnStart?: boolean;
|
||||
notifyOnSuccess?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
notifyOnSlaEvents?: boolean;
|
||||
}
|
||||
|
||||
/** Channel list when migrated, else the legacy single-webhook fields — mirrors TenantConfiguration.GetNotificationChannels. */
|
||||
function resolveChannels(config: TenantConfiguration): ChannelView[] {
|
||||
if (config.notificationChannelsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(config.notificationChannelsJson);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed as ChannelView[];
|
||||
} catch { /* malformed → fall back to legacy display */ }
|
||||
}
|
||||
if (config.webhookUrl && config.webhookProviderType) {
|
||||
return [{ id: 'legacy', name: 'Default (legacy)', providerType: config.webhookProviderType, url: config.webhookUrl, enabled: true, notifyOnStart: config.webhookNotifyOnStart, notifyOnSuccess: config.webhookNotifyOnSuccess, notifyOnFailure: config.webhookNotifyOnFailure, notifyOnSlaEvents: true }];
|
||||
}
|
||||
if (config.teamsWebhookUrl) {
|
||||
return [{ id: 'legacy', name: 'Default (legacy)', providerType: 1, url: config.teamsWebhookUrl, enabled: true, notifyOnStart: config.teamsNotifyOnStart, notifyOnSuccess: config.teamsNotifyOnSuccess, notifyOnFailure: config.teamsNotifyOnFailure, notifyOnSlaEvents: true }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function ChannelRows({ config }: { config: TenantConfiguration }) {
|
||||
const channels = resolveChannels(config);
|
||||
if (channels.length === 0) {
|
||||
return <ConfigRow label="Provider" display={WEBHOOK_PROVIDERS[0]} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{channels.map((ch, i) => (
|
||||
<tr key={ch.id ?? i} className={i > 0 ? 'border-t-2 border-gray-200 dark:border-gray-600' : ''}>
|
||||
<td colSpan={2} className="p-0">
|
||||
<table className="w-full">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<ConfigRow label="Channel" display={`${ch.name || ch.id}${ch.enabled === false ? ' (disabled)' : ''}`} />
|
||||
<ConfigRow label="Provider" display={WEBHOOK_PROVIDERS[ch.providerType ?? 0] ?? 'Unknown'} />
|
||||
<ConfigRow label="Webhook URL" display={maskSasUrl(ch.url)} />
|
||||
<ConfigRow label="Notify On Start" display={formatValue(ch.notifyOnStart ?? false)} />
|
||||
<ConfigRow label="Notify On Success" display={formatValue(ch.notifyOnSuccess ?? false)} />
|
||||
<ConfigRow label="Notify On Failure" display={formatValue(ch.notifyOnFailure ?? false)} />
|
||||
<ConfigRow label="Notify On SLA" display={formatValue(ch.notifyOnSlaEvents ?? false)} />
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────────────
|
||||
@@ -349,13 +210,13 @@ export function SectionTenantConfigReport() {
|
||||
|
||||
const [tenants, setTenants] = useState<TenantInfo[]>([]);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||||
// Mount-time clock for trial-expiry checks — render must stay pure
|
||||
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
const [config, setConfig] = useState<TenantConfig | null>(null);
|
||||
const [config, setConfig] = useState<TenantConfiguration | null>(null);
|
||||
const [flags, setFlags] = useState<TenantFeatureFlagsResponse | null>(null);
|
||||
const [effective, setEffective] = useState<AgentConfigResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingTenants, setLoadingTenants] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sideError, setSideError] = useState<string | null>(null);
|
||||
|
||||
// Fetch tenant list
|
||||
useEffect(() => {
|
||||
@@ -379,20 +240,34 @@ export function SectionTenantConfigReport() {
|
||||
fetchTenants();
|
||||
}, [user?.isGlobalAdmin, getAccessToken]);
|
||||
|
||||
// Fetch config for selected tenant
|
||||
// Fetch the stored config plus the two backend-resolved views of it. The stored config is
|
||||
// the report; feature-flags (effective entitlement, header) and the effective agent config
|
||||
// (runtime column) are fail-soft — a notice replaces them instead of blocking the page.
|
||||
const fetchConfig = useCallback(async () => {
|
||||
if (!selectedTenantId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchJson<TenantConfig>(
|
||||
api.config.tenant(selectedTenantId),
|
||||
getAccessToken,
|
||||
);
|
||||
setConfig(data);
|
||||
setSideError(null);
|
||||
const [configResult, flagsResult, effectiveResult] = await Promise.allSettled([
|
||||
fetchJson<TenantConfiguration>(api.config.tenant(selectedTenantId), getAccessToken),
|
||||
fetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(selectedTenantId), getAccessToken),
|
||||
fetchJson<AgentConfigResponse>(api.config.effectiveAgentConfig(selectedTenantId), getAccessToken),
|
||||
]);
|
||||
if (configResult.status === 'rejected') throw configResult.reason;
|
||||
setConfig(configResult.value);
|
||||
setFlags(flagsResult.status === 'fulfilled' ? flagsResult.value : null);
|
||||
setEffective(effectiveResult.status === 'fulfilled' ? effectiveResult.value : null);
|
||||
const sideFailures = [
|
||||
flagsResult.status === 'rejected' ? `feature flags: ${apiErrorText(flagsResult.reason)}` : null,
|
||||
effectiveResult.status === 'rejected' ? `effective agent config: ${apiErrorText(effectiveResult.reason)}` : null,
|
||||
].filter((s): s is string => s !== null);
|
||||
setSideError(sideFailures.length ? `Backend-resolved views unavailable — ${sideFailures.join('; ')}` : null);
|
||||
} catch (err) {
|
||||
setError(apiErrorText(err));
|
||||
setConfig(null);
|
||||
setFlags(null);
|
||||
setEffective(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -405,31 +280,6 @@ export function SectionTenantConfigReport() {
|
||||
void run();
|
||||
}, [fetchConfig]);
|
||||
|
||||
// Computed runtime
|
||||
const runtime = config ? computeRuntime(config) : null;
|
||||
|
||||
// Webhook display helper: channel list when migrated, else legacy single-webhook fields
|
||||
// (mirrors the backend's GetNotificationChannels fallback order).
|
||||
const webhookChannels = config
|
||||
? (() => {
|
||||
if (config.notificationChannelsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(config.notificationChannelsJson);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed as { id: string; name?: string; providerType?: number; url?: string; enabled?: boolean; notifyOnStart?: boolean; notifyOnSuccess?: boolean; notifyOnFailure?: boolean; notifyOnSlaEvents?: boolean }[];
|
||||
}
|
||||
} catch { /* malformed → fall back to legacy display */ }
|
||||
}
|
||||
if (config.webhookUrl && config.webhookProviderType) {
|
||||
return [{ id: 'legacy', name: 'Default (legacy)', providerType: config.webhookProviderType, url: config.webhookUrl, enabled: true, notifyOnStart: config.webhookNotifyOnStart, notifyOnSuccess: config.webhookNotifyOnSuccess, notifyOnFailure: config.webhookNotifyOnFailure, notifyOnSlaEvents: true }];
|
||||
}
|
||||
if (config.teamsWebhookUrl) {
|
||||
return [{ id: 'legacy', name: 'Default (legacy)', providerType: 1, url: config.teamsWebhookUrl, enabled: true, notifyOnStart: config.teamsNotifyOnStart, notifyOnSuccess: config.teamsNotifyOnSuccess, notifyOnFailure: config.teamsNotifyOnFailure, notifyOnSlaEvents: true }];
|
||||
}
|
||||
return [];
|
||||
})()
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
@@ -440,12 +290,12 @@ export function SectionTenantConfigReport() {
|
||||
Read-only overview of all tenant configuration and runtime parameters
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm text-gray-500 whitespace-nowrap">Tenant:</label>
|
||||
<select
|
||||
value={selectedTenantId}
|
||||
onChange={(e) => setSelectedTenantId(e.target.value)}
|
||||
className="text-sm border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 max-w-xs bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
className="text-sm border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 min-w-0 flex-1 max-w-xs sm:flex-none bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
disabled={loadingTenants}
|
||||
>
|
||||
{loadingTenants && <option>Loading...</option>}
|
||||
@@ -509,6 +359,34 @@ export function SectionTenantConfigReport() {
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{config.updatedBy || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Effective entitlement — backend-resolved (feature-flags), not the stored plan tier */}
|
||||
{flags && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm mt-3 pt-3 border-t border-purple-200 dark:border-purple-800">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block">Effective Edition</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{flags.edition}
|
||||
<span className="ml-1 text-xs text-gray-500 dark:text-gray-400">via {flags.editionSource}</span>
|
||||
{flags.isTrial && flags.trialExpiresUtc ? ` (trial until ${formatDate(flags.trialExpiresUtc)})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block">MCP Usage Plan</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{flags.entitlements.mcpUsagePlan || '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block">Delegated Tenant Slots</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{flags.entitlements.maxDelegatedTenants}
|
||||
{!flags.entitlements.delegatedAdminAllowed && <span className="ml-1 text-xs text-gray-500 dark:text-gray-400">(delegation not included)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block">Retention Cap (days)</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{flags.entitlements.retentionCapDays}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{config.disabled && (
|
||||
<div className="mt-3 p-2 bg-red-100 dark:bg-red-900/40 rounded text-sm text-red-700 dark:text-red-300 font-medium">
|
||||
TENANT DISABLED{config.disabledReason ? `: ${config.disabledReason}` : ''}
|
||||
@@ -522,6 +400,12 @@ export function SectionTenantConfigReport() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sideError && (
|
||||
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg text-sm text-amber-800 dark:text-amber-300">
|
||||
{sideError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mb-4 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-700 rounded" />
|
||||
@@ -533,177 +417,54 @@ export function SectionTenantConfigReport() {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Tenant Configuration</h2>
|
||||
|
||||
<Section title="Tenant Status">
|
||||
<ConfigRow label="Disabled" value={config.disabled} configKey="disabled" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Disabled Reason" value={config.disabledReason} />
|
||||
<ConfigRow label="Disabled Until" value={config.disabledUntil} isDate />
|
||||
<ConfigRow label="Onboarded At" value={config.onboardedAt} isDate />
|
||||
<ConfigRow label="Contact Email" value={config.contactEmail} />
|
||||
<ConfigRow label="Company" value={config.companyName} />
|
||||
<ConfigRow
|
||||
label="Plan Tier"
|
||||
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
|
||||
configKey="planTier"
|
||||
defaults={DEFAULTS}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Security & Validation">
|
||||
<ConfigRow label="Validate Autopilot Device" value={config.validateAutopilotDevice} configKey="validateAutopilotDevice" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Validate Corporate Identifier" value={config.validateCorporateIdentifier} configKey="validateCorporateIdentifier" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Allow Insecure Agent Requests" value={config.allowInsecureAgentRequests} configKey="allowInsecureAgentRequests" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Device API Rate Limit Override" value={config.customRateLimitRequestsPerMinute} configKey="customRateLimitRequestsPerMinute" defaults={DEFAULTS} />
|
||||
<ConfigRow label="MCP & Integrations API Rate Limit Override" value={config.customUserRateLimitRequestsPerMinute} configKey="customUserRateLimitRequestsPerMinute" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Hardware Whitelist">
|
||||
<ConfigRow label="Manufacturers" value={config.manufacturerWhitelist} configKey="manufacturerWhitelist" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Models" value={config.modelWhitelist} configKey="modelWhitelist" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Data Management">
|
||||
<ConfigRow label="Data Retention (days)" value={config.dataRetentionDays} configKey="dataRetentionDays" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Session Timeout (hours)" value={config.sessionTimeoutHours} configKey="sessionTimeoutHours" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Max NDJSON Payload (MB)" value={config.maxNdjsonPayloadSizeMB} configKey="maxNdjsonPayloadSizeMB" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Agent Collectors">
|
||||
<ConfigRow label="Performance Collector" value={config.enablePerformanceCollector} configKey="enablePerformanceCollector" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Perf. Interval (sec)" value={config.performanceCollectorIntervalSeconds} configKey="performanceCollectorIntervalSeconds" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Hello Wait Timeout (sec)" value={config.helloWaitTimeoutSeconds} configKey="helloWaitTimeoutSeconds" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Auth Circuit Breaker">
|
||||
<ConfigRow label="Max Auth Failures" value={config.maxAuthFailures} configKey="maxAuthFailures" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Auth Failure Timeout (min)" value={config.authFailureTimeoutMinutes} configKey="authFailureTimeoutMinutes" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Agent Max Lifetime (min)" value={config.agentMaxLifetimeMinutes} configKey="agentMaxLifetimeMinutes" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Agent Behavior">
|
||||
<ConfigRow label="Self-Destruct On Complete" value={config.selfDestructOnComplete} configKey="selfDestructOnComplete" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Keep Log File" value={config.keepLogFile} configKey="keepLogFile" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Reboot On Complete" value={config.rebootOnComplete} configKey="rebootOnComplete" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Reboot Delay (sec)" value={config.rebootDelaySeconds} configKey="rebootDelaySeconds" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Geo-Location" value={config.enableGeoLocation} configKey="enableGeoLocation" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Timezone Auto-Set" value={config.enableTimezoneAutoSet} configKey="enableTimezoneAutoSet" defaults={DEFAULTS} />
|
||||
<ConfigRow label="DO GroupId Auto-Set" value={config.enableDoGroupIdAutoSet} configKey="enableDoGroupIdAutoSet" defaults={DEFAULTS} />
|
||||
<ConfigRow label="NTP Server" value={config.ntpServer} configKey="ntpServer" defaults={DEFAULTS} />
|
||||
<ConfigRow label="IME Match Log" value={config.enableImeMatchLog} configKey="enableImeMatchLog" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Gather Rule Debug Log" value={config.enableGatherRuleDebugLog} configKey="enableGatherRuleDebugLog" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Continue-Anyway Observation" value={config.enableEspContinueAnywayObservation} configKey="enableEspContinueAnywayObservation" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Log Level" value={config.logLevel} configKey="logLevel" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Max Batch Size" value={config.maxBatchSize} configKey="maxBatchSize" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Show Script Output" value={config.showScriptOutput} configKey="showScriptOutput" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Send Trace Events" value={config.sendTraceEvents} configKey="sendTraceEvents" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Enrollment Summary">
|
||||
<ConfigRow label="Show Summary" value={config.showEnrollmentSummary} configKey="showEnrollmentSummary" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Timeout (sec)" value={config.enrollmentSummaryTimeoutSeconds} configKey="enrollmentSummaryTimeoutSeconds" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Branding Image URL" value={config.enrollmentSummaryBrandingImageUrl} configKey="enrollmentSummaryBrandingImageUrl" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Launch Retry (sec)" value={config.enrollmentSummaryLaunchRetrySeconds} configKey="enrollmentSummaryLaunchRetrySeconds" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Analyzers">
|
||||
<ConfigRow label="Local Admin Analyzer" value={config.enableLocalAdminAnalyzer} configKey="enableLocalAdminAnalyzer" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Software Inventory Analyzer" value={config.enableSoftwareInventoryAnalyzer} configKey="enableSoftwareInventoryAnalyzer" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Allowed Local Admin Accounts" value={config.localAdminAllowedAccountsJson} configKey="localAdminAllowedAccountsJson" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Webhooks">
|
||||
{webhookChannels.length === 0 && (
|
||||
<ConfigRow label="Provider" value={WEBHOOK_PROVIDERS[0] ?? 'None'} />
|
||||
)}
|
||||
{webhookChannels.map((ch, i) => (
|
||||
<div key={ch.id ?? i} className={i > 0 ? 'mt-2 pt-2 border-t border-gray-100 dark:border-gray-700' : ''}>
|
||||
<ConfigRow label="Channel" value={`${ch.name || ch.id}${ch.enabled === false ? ' (disabled)' : ''}`} />
|
||||
<ConfigRow label="Provider" value={WEBHOOK_PROVIDERS[ch.providerType ?? 0] ?? 'Unknown'} />
|
||||
<ConfigRow label="Webhook URL" value={ch.url} masked />
|
||||
<ConfigRow label="Notify On Start" value={ch.notifyOnStart ?? false} />
|
||||
<ConfigRow label="Notify On Success" value={ch.notifyOnSuccess ?? false} />
|
||||
<ConfigRow label="Notify On Failure" value={ch.notifyOnFailure ?? false} />
|
||||
<ConfigRow label="Notify On SLA" value={ch.notifyOnSlaEvents ?? false} />
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Diagnostics">
|
||||
<ConfigRow label="Upload Mode" value={config.diagnosticsUploadMode} configKey="diagnosticsUploadMode" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Blob SAS URL" value={config.diagnosticsBlobSasUrl} masked />
|
||||
<ConfigRow label="Custom Log Paths" value={config.diagnosticsLogPathsJson} configKey="diagnosticsLogPathsJson" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Feature Flags (Global)">
|
||||
<ConfigRow label="Bootstrap Token Enabled" value={config.bootstrapTokenEnabled} configKey="bootstrapTokenEnabled" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Unrestricted Mode Enabled" value={config.unrestrictedModeEnabled} configKey="unrestrictedModeEnabled" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Unrestricted Mode Active" value={config.unrestrictedMode} configKey="unrestrictedMode" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
{TENANT_REPORT_SECTIONS.map(({ section, rows }) => (
|
||||
<Section key={section} title={section}>
|
||||
{section === 'Webhooks' && <ChannelRows config={config} />}
|
||||
{rows.map(({ key, row, default: def }) => {
|
||||
const value = config[key];
|
||||
return (
|
||||
<ConfigRow
|
||||
key={key}
|
||||
label={row.label}
|
||||
display={formatByKind(row.kind, value)}
|
||||
highlight={!row.informational && isNonDefault(value, def)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ────── RIGHT: Runtime Parameters (Agent Config) ────── */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Runtime Parameters (Agent)</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
These are the effective values the agent receives at runtime, with defaults applied for unset fields.
|
||||
What an agent of this tenant receives from the backend right now, resolved server-side.
|
||||
The tag says which knob a value comes from: tenant config, global config, the tenant's entitlement, or a fixed agent default.
|
||||
</p>
|
||||
|
||||
{runtime && (
|
||||
<>
|
||||
<Section title="Upload & Batching">
|
||||
<ConfigRow label="Upload Interval (sec)" value={runtime.uploadIntervalSeconds} configKey="uploadIntervalSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Max Batch Size" value={runtime.maxBatchSize} configKey="maxBatchSize" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Agent Behavior">
|
||||
<ConfigRow label="Self-Destruct" value={runtime.selfDestructOnComplete} configKey="selfDestructOnComplete" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Keep Log File" value={runtime.keepLogFile} configKey="keepLogFile" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Reboot On Complete" value={runtime.rebootOnComplete} configKey="rebootOnComplete" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Reboot Delay (sec)" value={runtime.rebootDelaySeconds} configKey="rebootDelaySeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Geo-Location" value={runtime.enableGeoLocation} configKey="enableGeoLocation" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Timezone Auto-Set" value={runtime.enableTimezoneAutoSet} configKey="enableTimezoneAutoSet" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="DO GroupId Auto-Set" value={runtime.enableDoGroupIdAutoSet} configKey="enableDoGroupIdAutoSet" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="NTP Server" value={runtime.ntpServer} configKey="ntpServer" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="IME Match Log" value={runtime.enableImeMatchLog} configKey="enableImeMatchLog" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Gather Rule Debug Log" value={runtime.enableGatherRuleDebugLog} configKey="enableGatherRuleDebugLog" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Continue-Anyway Observation" value={runtime.enableEspContinueAnywayObservation} configKey="enableEspContinueAnywayObservation" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Log Level" value={runtime.logLevel} configKey="logLevel" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Send Trace Events" value={runtime.sendTraceEvents} configKey="sendTraceEvents" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Unrestricted Mode" value={runtime.unrestrictedMode} configKey="unrestrictedMode" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Auth Circuit Breaker">
|
||||
<ConfigRow label="Max Auth Failures" value={runtime.maxAuthFailures} configKey="maxAuthFailures" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Auth Failure Timeout (min)" value={runtime.authFailureTimeoutMinutes} configKey="authFailureTimeoutMinutes" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Enrollment Summary">
|
||||
<ConfigRow label="Show Summary" value={runtime.showEnrollmentSummary} configKey="showEnrollmentSummary" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Timeout (sec)" value={runtime.enrollmentSummaryTimeoutSeconds} configKey="enrollmentSummaryTimeoutSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Branding Image URL" value={runtime.enrollmentSummaryBrandingImageUrl} />
|
||||
<ConfigRow label="Launch Retry (sec)" value={runtime.enrollmentSummaryLaunchRetrySeconds} configKey="enrollmentSummaryLaunchRetrySeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Collectors">
|
||||
<ConfigRow label="Performance Collector" value={runtime.enablePerformanceCollector} configKey="enablePerformanceCollector" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Perf. Interval (sec)" value={runtime.performanceIntervalSeconds} configKey="performanceIntervalSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Idle Timeout (min)" value={runtime.collectorIdleTimeoutMinutes} configKey="collectorIdleTimeoutMinutes" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Agent Self-Metrics" value={runtime.enableAgentSelfMetrics} configKey="enableAgentSelfMetrics" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Self-Metrics Interval (sec)" value={runtime.agentSelfMetricsIntervalSeconds} configKey="agentSelfMetricsIntervalSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Hello Wait Timeout (sec)" value={runtime.helloWaitTimeoutSeconds} configKey="helloWaitTimeoutSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Agent Max Lifetime (min)" value={runtime.agentMaxLifetimeMinutes} configKey="agentMaxLifetimeMinutes" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Diagnostics">
|
||||
<ConfigRow label="Upload Enabled" value={runtime.diagnosticsUploadEnabled} />
|
||||
<ConfigRow label="Upload Mode" value={runtime.diagnosticsUploadMode} configKey="diagnosticsUploadMode" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Analyzers">
|
||||
<ConfigRow label="Local Admin Analyzer" value={runtime.enableLocalAdminAnalyzer} configKey="enableLocalAdminAnalyzer" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Software Inventory Analyzer" value={runtime.enableSoftwareInventoryAnalyzer} configKey="enableSoftwareInventoryAnalyzer" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
</>
|
||||
{!effective && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Effective agent configuration not available.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effective && RUNTIME_REPORT_SECTIONS.map(({ section, rows }) => (
|
||||
<Section key={section} title={section} collapsed={RUNTIME_SECTIONS_COLLAPSED.has(section)}>
|
||||
{rows.map(({ key, row, value: read, default: def }) => {
|
||||
const value = read(effective);
|
||||
return (
|
||||
<ConfigRow
|
||||
key={key}
|
||||
label={row.label}
|
||||
sourceTag={SOURCE_LABELS[row.source]}
|
||||
display={formatByKind(row.kind, value)}
|
||||
highlight={!row.informational && isNonDefault(value, def)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Parity pins for the Tenant Config Report catalog.
|
||||
*
|
||||
* The catalog is compile-time exhaustive over the generated wire types; this file adds the
|
||||
* runtime half against the shared manifest (the C#-reflected field lists and initializer
|
||||
* defaults, independent of the TS types): every field is catalogued, every catalogued field
|
||||
* has a default to compare against, and the value accessors read the effective agent config
|
||||
* the backend returns. The derivation rules themselves are pinned in the backend
|
||||
* (AgentConfigResolverTests) — nothing is re-computed here.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
|
||||
import type { AgentConfigResponse } from "@/utils/wire-types.generated";
|
||||
import {
|
||||
ANALYZER_FIELDS,
|
||||
COLLECTOR_FIELDS,
|
||||
isExcluded,
|
||||
isNonDefault,
|
||||
RUNTIME_FIELDS,
|
||||
RUNTIME_REPORT_SECTIONS,
|
||||
RUNTIME_SECTIONS_COLLAPSED,
|
||||
TENANT_FIELDS,
|
||||
TENANT_REPORT_SECTIONS,
|
||||
} from "../tenantConfigReportCatalog";
|
||||
|
||||
const manifestFields = [...SHARED_MANIFEST.tenantConfiguration.fields] as string[];
|
||||
const manifestDefaults = SHARED_MANIFEST.tenantConfiguration.defaults as Record<string, unknown>;
|
||||
|
||||
describe("TENANT_FIELDS parity with the C# field manifest", () => {
|
||||
it("catalogues every wire field of TenantConfiguration (rendered or excluded with a reason)", () => {
|
||||
const missing = manifestFields.filter((f) => !(f in TENANT_FIELDS));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("has no key outside the manifest (a removed C# property must leave the catalog)", () => {
|
||||
const stale = Object.keys(TENANT_FIELDS).filter((k) => !manifestFields.includes(k));
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("the manifest carries an initializer default for every field", () => {
|
||||
const withoutDefault = manifestFields.filter((f) => !(f in manifestDefaults));
|
||||
expect(withoutDefault).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives every exclusion a reason and every row a non-empty label", () => {
|
||||
for (const [key, spec] of Object.entries(TENANT_FIELDS)) {
|
||||
if (isExcluded(spec)) expect(spec.excluded, key).not.toBe("");
|
||||
else expect(spec.label, key).not.toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the fields the operator asked for by name", () => {
|
||||
const rendered = new Set(TENANT_REPORT_SECTIONS.flatMap((s) => s.rows.map((r) => r.key as string)));
|
||||
for (const key of [
|
||||
"payingCustomer", "maxDelegatedTenantsOverride", "mcpUsagePlanOverride", "managedByProTenantId",
|
||||
"trialConsumed", "proDowngradedUtc", "validateDeviceAssociation", "validateCloudPcDevice",
|
||||
"validateIntuneDeviceBinding", "sessionGraceHours", "absoluteMaxSessionHours",
|
||||
"diagnosticsUploadDestination", "webhookNotifyOnHardwareRejection", "entraAppRolesEnabled",
|
||||
"slaTargetSuccessRate", "slaConsecutiveFailureThreshold", "keepAwakeDuringUserEsp",
|
||||
]) {
|
||||
expect(rendered.has(key), key).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders each field in exactly one section, with its manifest default attached", () => {
|
||||
const seen = new Map<string, number>();
|
||||
for (const { rows } of TENANT_REPORT_SECTIONS) {
|
||||
for (const { key, default: def } of rows) {
|
||||
seen.set(key, (seen.get(key) ?? 0) + 1);
|
||||
expect(def, key).toEqual(manifestDefaults[key]);
|
||||
}
|
||||
}
|
||||
expect([...seen.entries()].filter(([, n]) => n !== 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it("secrets, masked values and timestamps are informational (never marked custom)", () => {
|
||||
for (const [key, spec] of Object.entries(TENANT_FIELDS)) {
|
||||
if (isExcluded(spec)) continue;
|
||||
if (spec.kind === "secret" || spec.kind === "masked" || spec.kind === "date") {
|
||||
expect(spec.informational, key).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("pins a few manifest defaults the report relies on", () => {
|
||||
expect(manifestDefaults.planTier).toBe("free");
|
||||
expect(manifestDefaults.dataRetentionDays).toBe(90);
|
||||
expect(manifestDefaults.diagnosticsUploadDestination).toBe("CustomerSas");
|
||||
expect(manifestDefaults.payingCustomer).toBe(false);
|
||||
// The one string default the table read also applies to a blank cell — a stored
|
||||
// "time.windows.com" must not read as custom.
|
||||
expect(manifestDefaults.ntpServer).toBe("time.windows.com");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Runtime catalog ──────────────────────────────────────────────────────────
|
||||
|
||||
const agentDefaults = SHARED_MANIFEST.agentConfig;
|
||||
|
||||
describe("runtime catalogs parity with the agent-config defaults manifest", () => {
|
||||
it.each([
|
||||
["responseDefaults", RUNTIME_FIELDS],
|
||||
["collectorDefaults", COLLECTOR_FIELDS],
|
||||
["analyzerDefaults", ANALYZER_FIELDS],
|
||||
] as const)("%s ↔ catalog: same key set", (section, record) => {
|
||||
const keys = Object.keys(agentDefaults[section]);
|
||||
expect(keys.filter((k) => !(k in record))).toEqual([]);
|
||||
expect(Object.keys(record).filter((k) => !keys.includes(k))).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders every runtime row in exactly one section", () => {
|
||||
const keys = RUNTIME_REPORT_SECTIONS.flatMap((s) => s.rows.map((r) => r.key));
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(keys).toContain("uploadIntervalSeconds");
|
||||
expect(keys).toContain("collectors.collectorIdleTimeoutMinutes");
|
||||
expect(keys).toContain("analyzers.enableConsoleBypassDetection");
|
||||
expect(keys).toContain("configVersion");
|
||||
expect(keys).not.toContain("deviceKillSignal");
|
||||
});
|
||||
|
||||
it("collapsed sections exist in the section list", () => {
|
||||
const sections = new Set(RUNTIME_REPORT_SECTIONS.map((s) => s.section));
|
||||
for (const s of RUNTIME_SECTIONS_COLLAPSED) expect(sections.has(s), s).toBe(true);
|
||||
});
|
||||
|
||||
it("value accessors read the top level and the two sub-objects of the response", () => {
|
||||
const response = {
|
||||
uploadIntervalSeconds: 10,
|
||||
collectors: { collectorIdleTimeoutMinutes: 25 },
|
||||
analyzers: { enableConsoleBypassDetection: false },
|
||||
diagnosticsLogPaths: [{ path: "a" }, { path: "b" }],
|
||||
} as unknown as AgentConfigResponse;
|
||||
const byKey = new Map(RUNTIME_REPORT_SECTIONS.flatMap((s) => s.rows.map((r) => [r.key, r] as const)));
|
||||
|
||||
expect(byKey.get("uploadIntervalSeconds")!.value(response)).toBe(10);
|
||||
expect(byKey.get("collectors.collectorIdleTimeoutMinutes")!.value(response)).toBe(25);
|
||||
expect(byKey.get("analyzers.enableConsoleBypassDetection")!.value(response)).toBe(false);
|
||||
expect(byKey.get("diagnosticsLogPaths")!.row.kind).toBe("count");
|
||||
expect(byKey.get("diagnosticsLogPaths")!.value(response)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("pins the agent defaults the report highlights against", () => {
|
||||
expect(agentDefaults.responseDefaults.uploadIntervalSeconds).toBe(10);
|
||||
expect(agentDefaults.responseDefaults.maxBatchSize).toBe(100);
|
||||
expect(agentDefaults.collectorDefaults.collectorIdleTimeoutMinutes).toBe(15);
|
||||
expect(agentDefaults.analyzerDefaults.enableIntegrityBypassAnalyzer).toBe(true);
|
||||
expect(agentDefaults.analyzerDefaults.enableRealmJoinWatcher).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNonDefault", () => {
|
||||
it("treats null, undefined and the empty string as one value and compares arrays by content", () => {
|
||||
expect(isNonDefault(undefined, null)).toBe(false);
|
||||
expect(isNonDefault("", null)).toBe(false);
|
||||
expect(isNonDefault("", "Off")).toBe(true);
|
||||
expect(isNonDefault(null, false)).toBe(true);
|
||||
expect(isNonDefault(false, false)).toBe(false);
|
||||
expect(isNonDefault([100, 1005, 1010], [100, 1005, 1010])).toBe(false);
|
||||
expect(isNonDefault([100, 42], [100, 1005, 1010])).toBe(true);
|
||||
});
|
||||
});
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Row catalog for the Tenant Config Report (admin/tenants/config-report).
|
||||
*
|
||||
* Both halves of the report are exhaustive over the generated wire types: `TENANT_FIELDS`
|
||||
* over every key of `TenantConfiguration` (GET config/{tenantId}) and `RUNTIME_FIELDS` /
|
||||
* `COLLECTOR_FIELDS` / `ANALYZER_FIELDS` over every key of `AgentConfigResponse` and its two
|
||||
* sub-objects (GET config/{tenantId}/effective-agent-config — the backend's own derivation,
|
||||
* nothing is re-computed here). A key is either rendered (a row with section and label) or
|
||||
* excluded with a reason — nothing can be silently forgotten: a new C# property forces the
|
||||
* wire-type regeneration (SharedManifestParityTests) and `tsc` then fails here until the key
|
||||
* is catalogued.
|
||||
*
|
||||
* The "custom" highlight compares a value against the C# initializer of the same property,
|
||||
* which the shared manifest carries as `defaults` (generated by the same test) — no
|
||||
* hand-copied default anywhere in this file.
|
||||
*/
|
||||
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
|
||||
import type {
|
||||
AgentConfigResponse,
|
||||
AnalyzerConfiguration,
|
||||
CollectorConfiguration,
|
||||
TenantConfiguration,
|
||||
} from "@/utils/wire-types.generated";
|
||||
|
||||
// ── Defaults from the manifest (compile-time complete over the wire types) ────
|
||||
|
||||
export const TENANT_DEFAULTS: Record<keyof TenantConfiguration, unknown> = SHARED_MANIFEST.tenantConfiguration.defaults;
|
||||
export const RUNTIME_DEFAULTS: Record<keyof AgentConfigResponse, unknown> = SHARED_MANIFEST.agentConfig.responseDefaults;
|
||||
export const COLLECTOR_DEFAULTS: Record<keyof CollectorConfiguration, unknown> = SHARED_MANIFEST.agentConfig.collectorDefaults;
|
||||
export const ANALYZER_DEFAULTS: Record<keyof AnalyzerConfiguration, unknown> = SHARED_MANIFEST.agentConfig.analyzerDefaults;
|
||||
|
||||
// ── Row model ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How a value is rendered. `secret` never shows the value, only whether one is set;
|
||||
* `count` shows the length of a list.
|
||||
*/
|
||||
export type RowKind = "value" | "date" | "masked" | "secret" | "count";
|
||||
|
||||
interface RowBase {
|
||||
label: string;
|
||||
kind?: RowKind;
|
||||
/** Informational rows (reasons, timestamps, identities, secrets) are never marked "custom". */
|
||||
informational?: true;
|
||||
}
|
||||
|
||||
export interface TenantRow extends RowBase {
|
||||
section: TenantSection;
|
||||
}
|
||||
|
||||
/** Which knob a runtime value is resolved from (AgentConfigResolver.Build). */
|
||||
export type RuntimeSource = "tenant" | "global" | "entitlement" | "fixed";
|
||||
|
||||
export interface RuntimeRow extends RowBase {
|
||||
section: RuntimeSection;
|
||||
source: RuntimeSource;
|
||||
}
|
||||
|
||||
export interface Excluded {
|
||||
excluded: string;
|
||||
}
|
||||
|
||||
export const isExcluded = <T extends object>(spec: T | Excluded): spec is Excluded =>
|
||||
"excluded" in spec;
|
||||
|
||||
export const TENANT_SECTIONS = [
|
||||
"Tenant Status",
|
||||
"Plan & Billing",
|
||||
"Identity & App Homing",
|
||||
"Security & Validation",
|
||||
"Hardware Whitelist",
|
||||
"Data Management",
|
||||
"Agent Collectors",
|
||||
"Auth Circuit Breaker",
|
||||
"Agent Behavior",
|
||||
"Enrollment Summary",
|
||||
"Analyzers",
|
||||
"Webhooks",
|
||||
"SLA Targets",
|
||||
"Diagnostics",
|
||||
"Feature Flags (Global)",
|
||||
] as const;
|
||||
export type TenantSection = (typeof TENANT_SECTIONS)[number];
|
||||
|
||||
// ── Tenant configuration (left column) ───────────────────────────────────────
|
||||
|
||||
const HEADER = { excluded: "shown in the tenant header card" } as const;
|
||||
const CHANNEL_BLOCK = { excluded: "rendered by the notification channel block (legacy fallback order)" } as const;
|
||||
|
||||
export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Excluded> = {
|
||||
tenantId: HEADER,
|
||||
domainName: HEADER,
|
||||
lastUpdated: HEADER,
|
||||
updatedBy: HEADER,
|
||||
|
||||
// Tenant Status
|
||||
disabled: { section: "Tenant Status", label: "Disabled" },
|
||||
disabledReason: { section: "Tenant Status", label: "Disabled Reason", informational: true },
|
||||
disabledUntil: { section: "Tenant Status", label: "Disabled Until", kind: "date", informational: true },
|
||||
mcpDisabled: { section: "Tenant Status", label: "MCP Disabled" },
|
||||
mcpDisabledReason: { section: "Tenant Status", label: "MCP Disabled Reason", informational: true },
|
||||
onboardedAt: { section: "Tenant Status", label: "Onboarded At", kind: "date", informational: true },
|
||||
onboardedBy: { section: "Tenant Status", label: "Onboarded By", informational: true },
|
||||
contactEmail: { section: "Tenant Status", label: "Contact Email", informational: true },
|
||||
companyName: { section: "Tenant Status", label: "Company", informational: true },
|
||||
|
||||
// Plan & Billing (stored values; the effective edition comes from feature-flags, header card)
|
||||
planTier: { section: "Plan & Billing", label: "Plan Tier (stored)" },
|
||||
trialExpiresUtc: { section: "Plan & Billing", label: "Trial Expires", kind: "date", informational: true },
|
||||
trialStartedUtc: { section: "Plan & Billing", label: "Trial Started", kind: "date", informational: true },
|
||||
trialGrantedBy: { section: "Plan & Billing", label: "Trial Granted By", informational: true },
|
||||
trialConsumed: { section: "Plan & Billing", label: "Trial Consumed" },
|
||||
proDowngradedUtc: { section: "Plan & Billing", label: "Pro Downgraded At", kind: "date", informational: true },
|
||||
payingCustomer: { section: "Plan & Billing", label: "Paying Customer" },
|
||||
maxDelegatedTenantsOverride: { section: "Plan & Billing", label: "Delegated Tenant Slots (override)" },
|
||||
mcpUsagePlanOverride: { section: "Plan & Billing", label: "MCP Usage Plan (override)" },
|
||||
managedByProTenantId: { section: "Plan & Billing", label: "Managed By Pro Tenant" },
|
||||
|
||||
// Identity & App Homing
|
||||
homedAppClientId: { section: "Identity & App Homing", label: "Homed App Client ID", informational: true },
|
||||
lastAuthClientId: { section: "Identity & App Homing", label: "Last Auth Client ID", informational: true },
|
||||
lastAuthClientIdSince: { section: "Identity & App Homing", label: "Last Auth Client ID Since", kind: "date", informational: true },
|
||||
entraAppRolesEnabled: { section: "Identity & App Homing", label: "Entra App Roles Enabled" },
|
||||
|
||||
// Security & Validation
|
||||
validateAutopilotDevice: { section: "Security & Validation", label: "Validate Autopilot Device" },
|
||||
validateCorporateIdentifier: { section: "Security & Validation", label: "Validate Corporate Identifier" },
|
||||
validateDeviceAssociation: { section: "Security & Validation", label: "Validate Device Association" },
|
||||
validateCloudPcDevice: { section: "Security & Validation", label: "Validate Cloud PC Device" },
|
||||
validateIntuneDeviceBinding: { section: "Security & Validation", label: "Validate Intune Device Binding" },
|
||||
allowInsecureAgentRequests: { section: "Security & Validation", label: "Allow Insecure Agent Requests" },
|
||||
customRateLimitRequestsPerMinute: { section: "Security & Validation", label: "Device API Rate Limit Override" },
|
||||
customUserRateLimitRequestsPerMinute: { section: "Security & Validation", label: "MCP & Integrations API Rate Limit Override" },
|
||||
|
||||
// Hardware Whitelist
|
||||
manufacturerWhitelist: { section: "Hardware Whitelist", label: "Manufacturers" },
|
||||
modelWhitelist: { section: "Hardware Whitelist", label: "Models" },
|
||||
webhookNotifyOnHardwareRejection: { section: "Hardware Whitelist", label: "Notify On Hardware Rejection" },
|
||||
|
||||
// Data Management
|
||||
dataRetentionDays: { section: "Data Management", label: "Data Retention (days)" },
|
||||
sessionTimeoutHours: { section: "Data Management", label: "Session Timeout (hours)" },
|
||||
sessionGraceHours: { section: "Data Management", label: "Session Grace (hours)" },
|
||||
absoluteMaxSessionHours: { section: "Data Management", label: "Absolute Max Session (hours)" },
|
||||
maxNdjsonPayloadSizeMB: { section: "Data Management", label: "Max NDJSON Payload (MB)" },
|
||||
|
||||
// Agent Collectors
|
||||
enablePerformanceCollector: { section: "Agent Collectors", label: "Performance Collector" },
|
||||
performanceCollectorIntervalSeconds: { section: "Agent Collectors", label: "Perf. Interval (sec)" },
|
||||
helloWaitTimeoutSeconds: { section: "Agent Collectors", label: "Hello Wait Timeout (sec)" },
|
||||
|
||||
// Auth Circuit Breaker
|
||||
maxAuthFailures: { section: "Auth Circuit Breaker", label: "Max Auth Failures" },
|
||||
authFailureTimeoutMinutes: { section: "Auth Circuit Breaker", label: "Auth Failure Timeout (min)" },
|
||||
agentMaxLifetimeMinutes: { section: "Auth Circuit Breaker", label: "Agent Max Lifetime (min)" },
|
||||
|
||||
// Agent Behavior
|
||||
selfDestructOnComplete: { section: "Agent Behavior", label: "Self-Destruct On Complete" },
|
||||
keepLogFile: { section: "Agent Behavior", label: "Keep Log File" },
|
||||
rebootOnComplete: { section: "Agent Behavior", label: "Reboot On Complete" },
|
||||
rebootDelaySeconds: { section: "Agent Behavior", label: "Reboot Delay (sec)" },
|
||||
enableGeoLocation: { section: "Agent Behavior", label: "Geo-Location" },
|
||||
enableTimezoneAutoSet: { section: "Agent Behavior", label: "Timezone Auto-Set" },
|
||||
enableDoGroupIdAutoSet: { section: "Agent Behavior", label: "DO GroupId Auto-Set" },
|
||||
ntpServer: { section: "Agent Behavior", label: "NTP Server" },
|
||||
keepAwakeDuringUserEsp: { section: "Agent Behavior", label: "Keep Awake During User ESP" },
|
||||
enableImeMatchLog: { section: "Agent Behavior", label: "IME Match Log" },
|
||||
enableGatherRuleDebugLog: { section: "Agent Behavior", label: "Gather Rule Debug Log" },
|
||||
enableEspContinueAnywayObservation: { section: "Agent Behavior", label: "Continue-Anyway Observation" },
|
||||
logLevel: { section: "Agent Behavior", label: "Log Level" },
|
||||
maxBatchSize: { section: "Agent Behavior", label: "Max Batch Size" },
|
||||
showScriptOutput: { section: "Agent Behavior", label: "Show Script Output" },
|
||||
sendTraceEvents: { section: "Agent Behavior", label: "Send Trace Events" },
|
||||
|
||||
// Enrollment Summary
|
||||
showEnrollmentSummary: { section: "Enrollment Summary", label: "Show Summary" },
|
||||
enrollmentSummaryTimeoutSeconds: { section: "Enrollment Summary", label: "Timeout (sec)" },
|
||||
enrollmentSummaryBrandingImageUrl: { section: "Enrollment Summary", label: "Branding Image URL" },
|
||||
enrollmentSummaryLaunchRetrySeconds: { section: "Enrollment Summary", label: "Launch Retry (sec)" },
|
||||
|
||||
// Analyzers
|
||||
enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer" },
|
||||
localAdminAllowedAccountsJson: { section: "Analyzers", label: "Allowed Local Admin Accounts" },
|
||||
enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer" },
|
||||
enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer" },
|
||||
enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection" },
|
||||
enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher" },
|
||||
|
||||
// Webhooks — channel list plus the legacy single-webhook fields it falls back to
|
||||
notificationChannelsJson: CHANNEL_BLOCK,
|
||||
webhookProviderType: CHANNEL_BLOCK,
|
||||
webhookUrl: CHANNEL_BLOCK,
|
||||
webhookNotifyOnSuccess: CHANNEL_BLOCK,
|
||||
webhookNotifyOnFailure: CHANNEL_BLOCK,
|
||||
webhookNotifyOnStart: CHANNEL_BLOCK,
|
||||
teamsWebhookUrl: CHANNEL_BLOCK,
|
||||
teamsNotifyOnSuccess: CHANNEL_BLOCK,
|
||||
teamsNotifyOnFailure: CHANNEL_BLOCK,
|
||||
teamsNotifyOnStart: CHANNEL_BLOCK,
|
||||
webhookCustomHeadersJson: { section: "Webhooks", label: "Custom Headers", kind: "secret", informational: true },
|
||||
|
||||
// SLA Targets
|
||||
slaTargetSuccessRate: { section: "SLA Targets", label: "Target Success Rate (%)" },
|
||||
slaTargetMaxDurationMinutes: { section: "SLA Targets", label: "Target Max Duration (min)" },
|
||||
slaTargetAppInstallSuccessRate: { section: "SLA Targets", label: "Target App Install Success Rate (%)" },
|
||||
slaNotifyOnSuccessRateBreach: { section: "SLA Targets", label: "Notify On Success Rate Breach" },
|
||||
slaSuccessRateNotifyThreshold: { section: "SLA Targets", label: "Success Rate Notify Threshold (%)" },
|
||||
slaNotifyOnDurationBreach: { section: "SLA Targets", label: "Notify On Duration Breach" },
|
||||
slaNotifyOnAppInstallBreach: { section: "SLA Targets", label: "Notify On App Install Breach" },
|
||||
slaNotifyOnConsecutiveFailures: { section: "SLA Targets", label: "Notify On Consecutive Failures" },
|
||||
slaConsecutiveFailureThreshold: { section: "SLA Targets", label: "Consecutive Failure Threshold" },
|
||||
|
||||
// Diagnostics
|
||||
diagnosticsUploadMode: { section: "Diagnostics", label: "Upload Mode" },
|
||||
diagnosticsUploadDestination: { section: "Diagnostics", label: "Upload Destination" },
|
||||
diagnosticsBlobSasUrl: { section: "Diagnostics", label: "Blob SAS URL", kind: "masked", informational: true },
|
||||
diagnosticsLogPathsJson: { section: "Diagnostics", label: "Custom Log Paths" },
|
||||
|
||||
// Feature Flags (Global)
|
||||
bootstrapTokenEnabled: { section: "Feature Flags (Global)", label: "Bootstrap Token Enabled" },
|
||||
unrestrictedModeEnabled: { section: "Feature Flags (Global)", label: "Unrestricted Mode Enabled (GA gate)" },
|
||||
unrestrictedMode: { section: "Feature Flags (Global)", label: "Unrestricted Mode (tenant toggle)" },
|
||||
};
|
||||
|
||||
// ── Runtime parameters (right column) ────────────────────────────────────────
|
||||
|
||||
export const RUNTIME_SECTIONS = [
|
||||
"Upload & Batching",
|
||||
"Agent Behavior",
|
||||
"Auth Circuit Breaker",
|
||||
"Enrollment Summary",
|
||||
"Collectors",
|
||||
"Collector Internals",
|
||||
"Diagnostics",
|
||||
"Analyzers",
|
||||
"Catalogs & Integrity",
|
||||
] as const;
|
||||
export type RuntimeSection = (typeof RUNTIME_SECTIONS)[number];
|
||||
|
||||
/** Sections rendered collapsed by default (agent class defaults, rarely relevant). */
|
||||
export const RUNTIME_SECTIONS_COLLAPSED: ReadonlySet<RuntimeSection> = new Set<RuntimeSection>(["Collector Internals"]);
|
||||
|
||||
const PER_DEVICE = { excluded: "per-device kill verdict, only on the agent channel" } as const;
|
||||
const SUB_OBJECT = { excluded: "sub-object, catalogued separately" } as const;
|
||||
|
||||
/** Agent class default, neither tenant- nor operator-tunable — shown once, collapsed. */
|
||||
const internal = (label: string): RuntimeRow => ({ section: "Collector Internals", label, source: "fixed" });
|
||||
|
||||
export const RUNTIME_FIELDS: Record<keyof AgentConfigResponse, RuntimeRow | Excluded> = {
|
||||
configVersion: { section: "Catalogs & Integrity", label: "Config Version", source: "fixed", informational: true },
|
||||
uploadIntervalSeconds: { section: "Upload & Batching", label: "Upload Interval (sec)", source: "fixed" },
|
||||
maxBatchSize: { section: "Upload & Batching", label: "Max Batch Size", source: "tenant" },
|
||||
|
||||
selfDestructOnComplete: { section: "Agent Behavior", label: "Self-Destruct", source: "tenant" },
|
||||
keepLogFile: { section: "Agent Behavior", label: "Keep Log File", source: "tenant" },
|
||||
rebootOnComplete: { section: "Agent Behavior", label: "Reboot On Complete", source: "tenant" },
|
||||
rebootDelaySeconds: { section: "Agent Behavior", label: "Reboot Delay (sec)", source: "tenant" },
|
||||
enableGeoLocation: { section: "Agent Behavior", label: "Geo-Location", source: "tenant" },
|
||||
enableTimezoneAutoSet: { section: "Agent Behavior", label: "Timezone Auto-Set", source: "tenant" },
|
||||
enableDoGroupIdAutoSet: { section: "Agent Behavior", label: "DO GroupId Auto-Set", source: "tenant" },
|
||||
ntpServer: { section: "Agent Behavior", label: "NTP Server", source: "tenant" },
|
||||
enableImeMatchLog: { section: "Agent Behavior", label: "IME Match Log", source: "tenant" },
|
||||
enableGatherRuleDebugLog: { section: "Agent Behavior", label: "Gather Rule Debug Log", source: "tenant" },
|
||||
enableEspContinueAnywayObservation: { section: "Agent Behavior", label: "Continue-Anyway Observation", source: "tenant" },
|
||||
logLevel: { section: "Agent Behavior", label: "Log Level", source: "tenant" },
|
||||
sendTraceEvents: { section: "Agent Behavior", label: "Send Trace Events", source: "tenant" },
|
||||
// TenantEntitlementService.IsUnrestrictedModeActive = edition entitlement && GA gate && tenant toggle.
|
||||
unrestrictedMode: { section: "Agent Behavior", label: "Unrestricted Mode (effective)", source: "entitlement" },
|
||||
allowAgentDowngrade: { section: "Agent Behavior", label: "Allow Agent Downgrade", source: "global" },
|
||||
|
||||
maxAuthFailures: { section: "Auth Circuit Breaker", label: "Max Auth Failures", source: "tenant" },
|
||||
authFailureTimeoutMinutes: { section: "Auth Circuit Breaker", label: "Auth Failure Timeout (min)", source: "tenant" },
|
||||
|
||||
showEnrollmentSummary: { section: "Enrollment Summary", label: "Show Summary", source: "tenant" },
|
||||
enrollmentSummaryTimeoutSeconds: { section: "Enrollment Summary", label: "Timeout (sec)", source: "tenant" },
|
||||
enrollmentSummaryBrandingImageUrl: { section: "Enrollment Summary", label: "Branding Image URL", source: "tenant" },
|
||||
enrollmentSummaryLaunchRetrySeconds: { section: "Enrollment Summary", label: "Launch Retry (sec)", source: "tenant" },
|
||||
|
||||
diagnosticsUploadEnabled: { section: "Diagnostics", label: "Upload Enabled", source: "tenant" },
|
||||
diagnosticsUploadMode: { section: "Diagnostics", label: "Upload Mode", source: "tenant" },
|
||||
diagnosticsLogPaths: { section: "Diagnostics", label: "Log Paths (global + tenant)", source: "global", kind: "count" },
|
||||
|
||||
collectors: SUB_OBJECT,
|
||||
analyzers: SUB_OBJECT,
|
||||
|
||||
gatherRules: { section: "Catalogs & Integrity", label: "Active Gather Rules", source: "tenant", kind: "count", informational: true },
|
||||
imeLogPatterns: { section: "Catalogs & Integrity", label: "Active IME Log Patterns", source: "tenant", kind: "count", informational: true },
|
||||
whiteGloveSealingPatternIds: { section: "Catalogs & Integrity", label: "White Glove Sealing Pattern IDs", source: "global", informational: true },
|
||||
latestAgentSha256: { section: "Catalogs & Integrity", label: "Latest Agent ZIP SHA-256", source: "global", informational: true },
|
||||
latestAgentExeSha256: { section: "Catalogs & Integrity", label: "Latest Agent EXE SHA-256", source: "global", informational: true },
|
||||
migrateToApiBaseUrl: { section: "Catalogs & Integrity", label: "Migrate To API Base URL", source: "global" },
|
||||
|
||||
deviceBlocked: PER_DEVICE,
|
||||
deviceKillSignal: PER_DEVICE,
|
||||
unblockAt: PER_DEVICE,
|
||||
};
|
||||
|
||||
export const COLLECTOR_FIELDS: Record<keyof CollectorConfiguration, RuntimeRow | Excluded> = {
|
||||
enablePerformanceCollector: { section: "Collectors", label: "Performance Collector", source: "tenant" },
|
||||
performanceIntervalSeconds: { section: "Collectors", label: "Perf. Interval (sec)", source: "tenant" },
|
||||
collectorIdleTimeoutMinutes: { section: "Collectors", label: "Idle Timeout (min)", source: "global" },
|
||||
desktopDetectorNoCandidateTimeoutMinutes: { section: "Collectors", label: "Desktop Detector No-Candidate Timeout (min)", source: "global" },
|
||||
enableAgentSelfMetrics: { section: "Collectors", label: "Agent Self-Metrics", source: "fixed" },
|
||||
agentSelfMetricsIntervalSeconds: { section: "Collectors", label: "Self-Metrics Interval (sec)", source: "fixed" },
|
||||
helloWaitTimeoutSeconds: { section: "Collectors", label: "Hello Wait Timeout (sec)", source: "tenant" },
|
||||
agentMaxLifetimeMinutes: { section: "Collectors", label: "Agent Max Lifetime (min)", source: "tenant" },
|
||||
modernDeploymentHarmlessEventIds: { section: "Collectors", label: "Modern Deployment Harmless Event IDs", source: "global" },
|
||||
enableDeliveryOptimizationCollector: internal("Delivery Optimization Collector"),
|
||||
deliveryOptimizationIntervalSeconds: internal("DO Interval (sec)"),
|
||||
enableOfficeInstallDetector: internal("Office Install Detector"),
|
||||
officeInstallSettleSeconds: internal("Office Install Settle (sec)"),
|
||||
enablePowerStateWatcher: internal("Power State Watcher"),
|
||||
stallProbeEnabled: internal("Stall Probe"),
|
||||
stallProbeThresholdsMinutes: internal("Stall Probe Thresholds (min)"),
|
||||
stallProbeTraceIndices: internal("Stall Probe Trace Indices"),
|
||||
stallProbeSources: internal("Stall Probe Sources"),
|
||||
sessionStalledAfterProbeIndex: internal("Session Stalled After Probe Index"),
|
||||
modernDeploymentWatcherEnabled: internal("Modern Deployment Watcher"),
|
||||
modernDeploymentLogLevelMax: internal("Modern Deployment Log Level Max"),
|
||||
modernDeploymentBackfillEnabled: internal("Modern Deployment Backfill"),
|
||||
modernDeploymentBackfillLookbackMinutes: internal("Modern Deployment Backfill Lookback (min)"),
|
||||
windowsUpdateWatcherEnabled: internal("Windows Update Watcher"),
|
||||
windowsUpdateTargetedEventIds: internal("Windows Update Targeted Event IDs"),
|
||||
windowsUpdateBackfillLookbackMinutes: internal("Windows Update Backfill Lookback (min)"),
|
||||
windowsUpdateChannelCensusEnabled: internal("Windows Update Channel Census"),
|
||||
mdmRebootPolicyWatcherEnabled: internal("MDM Reboot Policy Watcher"),
|
||||
mdmRebootPolicyBackfillLookbackMinutes: internal("MDM Reboot Policy Backfill Lookback (min)"),
|
||||
enableSystemTimelineWatcher: internal("System Timeline Watcher"),
|
||||
systemEventBackfillLookbackMinutes: internal("System Event Backfill Lookback (min)"),
|
||||
};
|
||||
|
||||
export const ANALYZER_FIELDS: Record<keyof AnalyzerConfiguration, RuntimeRow | Excluded> = {
|
||||
enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer", source: "tenant" },
|
||||
localAdminAllowedAccounts: { section: "Analyzers", label: "Allowed Local Admin Accounts", source: "tenant" },
|
||||
enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer", source: "tenant" },
|
||||
enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer", source: "tenant" },
|
||||
enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher", source: "tenant" },
|
||||
keepAwakeDuringUserEsp: { section: "Analyzers", label: "Keep Awake During User ESP", source: "tenant" },
|
||||
enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection", source: "tenant" },
|
||||
};
|
||||
|
||||
// ── Ordered views for rendering ──────────────────────────────────────────────
|
||||
|
||||
/** One rendered row: where its value and default come from, resolved against a response. */
|
||||
export interface CataloguedRuntimeRow {
|
||||
key: string;
|
||||
row: RuntimeRow;
|
||||
value: (r: AgentConfigResponse) => unknown;
|
||||
default: unknown;
|
||||
}
|
||||
|
||||
export interface CataloguedTenantRow {
|
||||
key: keyof TenantConfiguration;
|
||||
row: TenantRow;
|
||||
default: unknown;
|
||||
}
|
||||
|
||||
function tenantRows(): Array<{ section: TenantSection; rows: CataloguedTenantRow[] }> {
|
||||
const bySection = new Map<TenantSection, CataloguedTenantRow[]>(TENANT_SECTIONS.map((s) => [s, []]));
|
||||
for (const [key, spec] of Object.entries(TENANT_FIELDS) as Array<[keyof TenantConfiguration, TenantRow | Excluded]>) {
|
||||
if (isExcluded(spec)) continue;
|
||||
bySection.get(spec.section)!.push({ key, row: spec, default: TENANT_DEFAULTS[key] });
|
||||
}
|
||||
return TENANT_SECTIONS.map((section) => ({ section, rows: bySection.get(section)! }));
|
||||
}
|
||||
|
||||
function runtimeRows(): Array<{ section: RuntimeSection; rows: CataloguedRuntimeRow[] }> {
|
||||
const bySection = new Map<RuntimeSection, CataloguedRuntimeRow[]>(RUNTIME_SECTIONS.map((s) => [s, []]));
|
||||
const add = (key: string, spec: RuntimeRow | Excluded, value: (r: AgentConfigResponse) => unknown, def: unknown) => {
|
||||
if (isExcluded(spec)) return;
|
||||
bySection.get(spec.section)!.push({ key, row: spec, value, default: def });
|
||||
};
|
||||
for (const [key, spec] of Object.entries(RUNTIME_FIELDS) as Array<[keyof AgentConfigResponse, RuntimeRow | Excluded]>) {
|
||||
add(key, spec, (r) => r[key], RUNTIME_DEFAULTS[key]);
|
||||
}
|
||||
for (const [key, spec] of Object.entries(COLLECTOR_FIELDS) as Array<[keyof CollectorConfiguration, RuntimeRow | Excluded]>) {
|
||||
add(`collectors.${key}`, spec, (r) => r.collectors?.[key], COLLECTOR_DEFAULTS[key]);
|
||||
}
|
||||
for (const [key, spec] of Object.entries(ANALYZER_FIELDS) as Array<[keyof AnalyzerConfiguration, RuntimeRow | Excluded]>) {
|
||||
add(`analyzers.${key}`, spec, (r) => r.analyzers?.[key], ANALYZER_DEFAULTS[key]);
|
||||
}
|
||||
return RUNTIME_SECTIONS.map((section) => ({ section, rows: bySection.get(section)! }));
|
||||
}
|
||||
|
||||
export const TENANT_REPORT_SECTIONS = tenantRows();
|
||||
export const RUNTIME_REPORT_SECTIONS = runtimeRows();
|
||||
|
||||
/**
|
||||
* Value equality for the "custom" highlight: null, undefined and the empty string are one
|
||||
* "unset" value (the table row stores a cleared string as "", the C# initializer is null),
|
||||
* arrays compare by content.
|
||||
*/
|
||||
export function isNonDefault(value: unknown, def: unknown): boolean {
|
||||
const isNil = (v: unknown) => v === null || v === undefined || v === "";
|
||||
const valueNil = isNil(value);
|
||||
const defNil = isNil(def);
|
||||
if (valueNil || defNil) return valueNil !== defNil;
|
||||
if (Array.isArray(value) && Array.isArray(def)) {
|
||||
return value.length !== def.length || value.some((v, i) => v !== def[i]);
|
||||
}
|
||||
return value !== def;
|
||||
}
|
||||
@@ -146,8 +146,9 @@ export default function AnalyzeRuleCard({
|
||||
>
|
||||
{/* Collapsed Header */}
|
||||
<div className="p-4 cursor-pointer select-none" onClick={() => { if (isEditing) return; onToggle(); }}>
|
||||
{/* Mobile: badges wrap and the title drops onto its own full-width row (order-last);
|
||||
≥sm: single line with the title in the middle, exactly as before. */}
|
||||
{/* Two rows on every breakpoint: the badge row on top, the title on its own full-width
|
||||
row below. The leading columns (toggle, severity, rule id) have fixed minimum widths
|
||||
so every card lines up vertically; the stats group is pushed to the right edge. */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
{isTemplateVariant ? (
|
||||
<span className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-amber-100 text-amber-600" title="Template — enabling creates a custom rule copy">
|
||||
@@ -170,6 +171,14 @@ export default function AnalyzeRuleCard({
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rule.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
)}
|
||||
{/* Severity and rule id are fixed-width columns (widest catalog values: "Critical",
|
||||
18-character ids) from sm up — mobile keeps its natural widths; a longer custom id overflows
|
||||
its column instead of being cut. */}
|
||||
<span className={`inline-flex items-center justify-center sm:min-w-[5.5rem] px-2.5 py-0.5 rounded-full text-xs font-semibold ${sevColor.bg} ${sevColor.text} flex-shrink-0`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sevColor.dot} mr-1.5`}></span>
|
||||
{rule.severity.charAt(0).toUpperCase() + rule.severity.slice(1)}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-gray-400 flex-shrink-0 min-w-[8.5rem] hidden sm:inline">{rule.ruleId}</span>
|
||||
{/* Read-only KO indicator. Visible only when the effective value is ON so the header
|
||||
stays quiet for the common case. The actual toggle lives inside the expanded details
|
||||
(bottom-left) to prevent accidental clicks. */}
|
||||
@@ -190,14 +199,6 @@ export default function AnalyzeRuleCard({
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${sevColor.bg} ${sevColor.text} flex-shrink-0`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sevColor.dot} mr-1.5`}></span>
|
||||
{rule.severity.charAt(0).toUpperCase() + rule.severity.slice(1)}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-gray-400 flex-shrink-0 hidden sm:inline">{rule.ruleId}</span>
|
||||
<div className="order-last w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
|
||||
<h3 className="text-sm font-semibold text-gray-900 sm:truncate">{rule.title}</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${catColor.bg} ${catColor.text} flex-shrink-0`}>
|
||||
{rule.category.charAt(0).toUpperCase() + rule.category.slice(1)}
|
||||
</span>
|
||||
@@ -222,40 +223,45 @@ export default function AnalyzeRuleCard({
|
||||
Based on {rule.derivedFromTemplateRuleId}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 flex-shrink-0 hidden md:inline" title="Confidence Threshold">Threshold: {rule.confidenceThreshold}%</span>
|
||||
{trend && trend.length > 0 && <RuleSparkline trend={trend} />}
|
||||
{hitRate != null && hitRate > 0 && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${
|
||||
hitRate >= 20 ? "bg-red-50 text-red-700 border border-red-200" :
|
||||
hitRate >= 5 ? "bg-amber-50 text-amber-700 border border-amber-200" :
|
||||
"bg-gray-50 text-gray-600 border border-gray-200"
|
||||
}`}
|
||||
title={`Fires on ${hitRate}% of evaluated sessions (${fireCount ?? 0} total fires in last 30 days)`}
|
||||
>
|
||||
{hitRate}% hit rate
|
||||
</span>
|
||||
)}
|
||||
{/* F3 regression badge — visible only while an alert episode is active (tracker row).
|
||||
Numbers in the tooltip mirror the bell notification; correlation wording only. */}
|
||||
{regression && (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-100 text-red-800 border border-red-300 flex-shrink-0"
|
||||
title={
|
||||
`Firing more often than usual: ${regression.windowRatePct}% of evaluated sessions in the last 7 days ` +
|
||||
`(${regression.windowFireCount}/${regression.windowSessionCount}) vs ${regression.baselineRatePct}% baseline ` +
|
||||
`(${regression.baselineFireCount}/${regression.baselineSessionCount} over 28 days)` +
|
||||
(regression.lift != null ? ` — lift ${regression.lift}x.` : " — new signal.") +
|
||||
(regression.dimension
|
||||
? ` ${regression.dimension.hitSharePct}% of affected sessions are on ${regression.dimension.dimension} ` +
|
||||
`${regression.dimension.value} vs ${regression.dimension.allSharePct}% of all sessions — correlated, not necessarily causal.`
|
||||
: "")
|
||||
}
|
||||
>
|
||||
↑ Regression
|
||||
</span>
|
||||
)}
|
||||
<svg className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${isExpanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
<svg className={`ml-auto w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ${isExpanded ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||||
{/* Title row: the title on the left, the stats group (threshold, sparkline, hit rate,
|
||||
regression) on the right — same right edge on every card */}
|
||||
<div className="w-full min-w-0 flex items-center gap-x-4">
|
||||
<h3 className="flex-1 min-w-0 text-sm font-semibold text-gray-900">{rule.title}</h3>
|
||||
<span className="text-xs text-gray-500 flex-shrink-0 hidden md:inline" title="Confidence Threshold">Threshold: {rule.confidenceThreshold}%</span>
|
||||
{trend && trend.length > 0 && <RuleSparkline trend={trend} />}
|
||||
{hitRate != null && hitRate > 0 && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${
|
||||
hitRate >= 20 ? "bg-red-50 text-red-700 border border-red-200" :
|
||||
hitRate >= 5 ? "bg-amber-50 text-amber-700 border border-amber-200" :
|
||||
"bg-gray-50 text-gray-600 border border-gray-200"
|
||||
}`}
|
||||
title={`Fires on ${hitRate}% of evaluated sessions (${fireCount ?? 0} total fires in last 30 days)`}
|
||||
>
|
||||
{hitRate}% hit rate
|
||||
</span>
|
||||
)}
|
||||
{/* F3 regression badge — visible only while an alert episode is active (tracker row).
|
||||
Numbers in the tooltip mirror the bell notification; correlation wording only. */}
|
||||
{regression && (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-100 text-red-800 border border-red-300 flex-shrink-0"
|
||||
title={
|
||||
`Firing more often than usual: ${regression.windowRatePct}% of evaluated sessions in the last 7 days ` +
|
||||
`(${regression.windowFireCount}/${regression.windowSessionCount}) vs ${regression.baselineRatePct}% baseline ` +
|
||||
`(${regression.baselineFireCount}/${regression.baselineSessionCount} over 28 days)` +
|
||||
(regression.lift != null ? ` — lift ${regression.lift}x.` : " — new signal.") +
|
||||
(regression.dimension
|
||||
? ` ${regression.dimension.hitSharePct}% of affected sessions are on ${regression.dimension.dimension} ` +
|
||||
`${regression.dimension.value} vs ${regression.dimension.allSharePct}% of all sessions — correlated, not necessarily causal.`
|
||||
: "")
|
||||
}
|
||||
>
|
||||
↑ Regression
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useEffect, useState } from "react";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
import { useNotifications } from "../../../contexts/NotificationContext";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import VulnerabilityExposurePanel, { type VulnerabilitySummary } from "@/components/VulnerabilityExposurePanel";
|
||||
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
|
||||
import type { CveExposureSummary } from "@/utils/wire-types.generated";
|
||||
import type { SoftwareTabScope, TimeRange } from "./types";
|
||||
import { rangeToDays } from "./types";
|
||||
import { ApiError, fetchJson } from "@/lib/apiClient";
|
||||
@@ -17,7 +18,7 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa
|
||||
const { addNotification } = useNotifications();
|
||||
const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope;
|
||||
|
||||
const [summary, setSummary] = useState<VulnerabilitySummary | null>(null);
|
||||
const [summary, setSummary] = useState<CveExposureSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// GA without a specific tenant selected sees the cross-tenant aggregate (incl. affected tenants).
|
||||
@@ -32,7 +33,7 @@ export default function VulnerabilitiesTab({ scope, timeRange }: { scope: Softwa
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = scopedApi.vulnerability(scope, days, TOP_N);
|
||||
const summary = await fetchJson<VulnerabilitySummary>(url, getAccessToken);
|
||||
const summary = await fetchJson<CveExposureSummary>(url, getAccessToken);
|
||||
if (cancelled) return;
|
||||
setSummary(summary);
|
||||
} catch (err) {
|
||||
|
||||
@@ -273,7 +273,7 @@ export default function AuditPage() {
|
||||
{/* Date window + filters */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
{/* Date pickers */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">From:</label>
|
||||
<input
|
||||
type="date"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { useWhatsNew } from "@/hooks/useWhatsNew";
|
||||
|
||||
// Session-scoped dismissal: sessionStorage is per-tab, so the banner stays hidden
|
||||
// across reloads in this tab (service-desk monitor use case) but reappears in every
|
||||
@@ -30,6 +31,7 @@ const linkClassName =
|
||||
|
||||
export function ActivelyDevelopedBanner() {
|
||||
const dismissed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const whatsNew = useWhatsNew();
|
||||
if (dismissed) return null;
|
||||
|
||||
const trackLink = (link: string) => trackEvent("dev_banner_link_clicked", { link });
|
||||
@@ -43,16 +45,14 @@ export function ActivelyDevelopedBanner() {
|
||||
<span className="font-semibold">Actively developed.</span>{" "}
|
||||
Autopilot Monitor recognizes a wide range of deployment scenarios and improves
|
||||
continuously — your reports directly shape it.{" "}
|
||||
If something looks off, check the{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/changelog/platform-changelog`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
If something looks off, check{" "}
|
||||
<button
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
onClick={() => trackLink("platform_changelog")}
|
||||
onClick={() => { trackLink("whats_new"); whatsNew.open("banner", "platform"); }}
|
||||
>
|
||||
Platform Changelog
|
||||
</a>{" "}
|
||||
What's new
|
||||
</button>{" "}
|
||||
or{" "}
|
||||
<a
|
||||
href={`${DOCS_URL}/troubleshooting/service-announcements`}
|
||||
|
||||
@@ -9,6 +9,12 @@ import { Session } from "../types";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { fuzzyContains } from "@/utils/fuzzy";
|
||||
import { buildUniqueValuesByField } from "./uniqueValuesByField";
|
||||
import {
|
||||
SESSION_SEARCH_FIELDS,
|
||||
buildSessionSearchMatcher,
|
||||
matchedSearchField,
|
||||
parseSessionSearchQuery,
|
||||
} from "../utils/sessionSearchQuery";
|
||||
import { SessionStatusBadge } from "@/components/SessionStatusBadge";
|
||||
import { TenantFilterBar } from "./TenantFilterBar";
|
||||
import type { DeleteTarget } from "../hooks/useDeleteSession";
|
||||
@@ -189,23 +195,6 @@ export function SessionTable({
|
||||
isExact: boolean;
|
||||
}
|
||||
|
||||
const SEARCH_FIELDS: { key: keyof Session; label: string }[] = [
|
||||
{ key: "deviceName", label: "Device" },
|
||||
{ key: "serialNumber", label: "Serial" },
|
||||
{ key: "model", label: "Model" },
|
||||
{ key: "manufacturer", label: "Manufacturer" },
|
||||
{ key: "sessionId", label: "Session ID" },
|
||||
{ key: "geoCountry", label: "Country" },
|
||||
{ key: "geoRegion", label: "Region" },
|
||||
{ key: "geoCity", label: "City" },
|
||||
{ key: "agentVersion", label: "Agent Version" },
|
||||
{ key: "osName", label: "OS Name" },
|
||||
{ key: "osBuild", label: "OS Build" },
|
||||
{ key: "osDisplayVersion", label: "OS Version" },
|
||||
{ key: "osEdition", label: "OS Edition" },
|
||||
{ key: "status", label: "Status" },
|
||||
];
|
||||
|
||||
// Defer expensive suggestion scans so rapid typing keeps the input responsive.
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
|
||||
@@ -213,41 +202,42 @@ export function SessionTable({
|
||||
const q = deferredSearchQuery.trim().toLowerCase();
|
||||
if (q.length < 2 || sessions.length === 0) return [];
|
||||
if (/^[><]=?\s*\d+$/.test(q)) return [];
|
||||
// Same grammar as the list filter (terms AND-ed, `-`, quotes, `field=value`).
|
||||
const parsed = parseSessionSearchQuery(deferredSearchQuery);
|
||||
const matcher = buildSessionSearchMatcher(deferredSearchQuery);
|
||||
if (!matcher) return [];
|
||||
|
||||
const exactResults: SearchSuggestion[] = [];
|
||||
const fuzzyResults: SearchSuggestion[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Phase 1: exact substring matches (highest priority)
|
||||
// Phase 1: sessions the query matches (highest priority), labelled with the field
|
||||
// that satisfied it — a qualified term names the field the user meant.
|
||||
for (const session of sessions) {
|
||||
if (exactResults.length >= 8) break;
|
||||
if (seen.has(session.sessionId)) continue;
|
||||
for (const f of SEARCH_FIELDS) {
|
||||
const val = session[f.key];
|
||||
if (val != null && String(val).toLowerCase().includes(q)) {
|
||||
seen.add(session.sessionId);
|
||||
exactResults.push({ session, matchedField: f.label, matchedValue: String(val), isExact: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Tenant domain is not a Session field -- it comes from the tenant list (cross-tenant only).
|
||||
if (seen.has(session.sessionId) || !globalAdminMode) continue;
|
||||
const domain = tenantDomainById.get(session.tenantId);
|
||||
if (domain && domain.toLowerCase().includes(q)) {
|
||||
seen.add(session.sessionId);
|
||||
exactResults.push({ session, matchedField: "Tenant", matchedValue: domain, isExact: true });
|
||||
}
|
||||
const domain = globalAdminMode ? tenantDomainById.get(session.tenantId) : undefined;
|
||||
if (!matcher(session, domain)) continue;
|
||||
const hit = matchedSearchField(parsed, session) ?? (domain ? { label: "Tenant", value: domain } : null);
|
||||
if (!hit) continue;
|
||||
seen.add(session.sessionId);
|
||||
exactResults.push({ session, matchedField: hit.label, matchedValue: hit.value, isExact: true });
|
||||
}
|
||||
|
||||
// Phase 2: Levenshtein fuzzy matches (fill remaining slots, min 3 chars for fuzzy)
|
||||
if (exactResults.length < 8 && q.length >= 3) {
|
||||
const maxDist = q.length <= 4 ? 1 : 2;
|
||||
// Phase 2: Levenshtein fuzzy matches (fill remaining slots) — only for a single free
|
||||
// term of 3+ chars; a qualifier or an exclusion states intent too precisely to fuzz.
|
||||
const [only] = parsed.include;
|
||||
const fuzzable = parsed.include.length === 1 && parsed.exclude.length === 0 && !only.field && only.text.length >= 3;
|
||||
if (fuzzable && exactResults.length < 8) {
|
||||
const needle = only.text;
|
||||
const maxDist = needle.length <= 4 ? 1 : 2;
|
||||
for (const session of sessions) {
|
||||
if (exactResults.length + fuzzyResults.length >= 8) break;
|
||||
if (seen.has(session.sessionId)) continue;
|
||||
for (const f of SEARCH_FIELDS) {
|
||||
for (const f of SESSION_SEARCH_FIELDS) {
|
||||
const val = session[f.key];
|
||||
if (val != null && fuzzyContains(String(val), q, maxDist)) {
|
||||
if (val != null && fuzzyContains(String(val), needle, maxDist)) {
|
||||
seen.add(session.sessionId);
|
||||
fuzzyResults.push({ session, matchedField: f.label, matchedValue: String(val), isExact: false });
|
||||
break;
|
||||
@@ -257,8 +247,6 @@ export function SessionTable({
|
||||
}
|
||||
|
||||
return [...exactResults, ...fuzzyResults];
|
||||
// SEARCH_FIELDS is a stable literal — intentionally omitted from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deferredSearchQuery, sessions, globalAdminMode, tenantDomainById]);
|
||||
|
||||
// Persist visible columns to localStorage
|
||||
@@ -574,7 +562,7 @@ export function SessionTable({
|
||||
<div className="mb-4 relative" ref={searchDropdownRef}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by device, serial, model, status, session ID, country, or duration (e.g., >30 for >30min)"
|
||||
placeholder="Search by device, serial, model, status, session ID, country, or duration (>30) — narrow with model=, manufacturer=, -term"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
onSearchQueryChange(e.target.value);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import type { Session } from "../types";
|
||||
import { buildSessionSearchMatcher, parseSessionSearchQuery } from "../utils/sessionSearchQuery";
|
||||
|
||||
const SESSIONS_PER_PAGE_KEY = "sessionsPerPage";
|
||||
const DEFAULT_SESSIONS_PER_PAGE = 10;
|
||||
@@ -101,14 +102,22 @@ export function useDashboardFilters({
|
||||
|
||||
// Track search usage (debounced — fires 1s after user stops typing).
|
||||
// Skip empty query so initial mount and "clear search" don't pollute the event.
|
||||
// `qualifiers` counts field-qualified terms (model=, manufacturer=, …) — a count, never
|
||||
// the values, so the event stays free of device names.
|
||||
useEffect(() => {
|
||||
if (!searchQuery) return;
|
||||
const timer = setTimeout(() => {
|
||||
trackEvent("session_searched", { queryLength: searchQuery.length });
|
||||
const { include, exclude } = parseSessionSearchQuery(searchQuery);
|
||||
const qualifiers = [...include, ...exclude].filter((t) => t.field).length;
|
||||
trackEvent("session_searched", { queryLength: searchQuery.length, qualifiers });
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Search grammar (terms AND-ed, `-` excludes, quotes, `field=value`) — see
|
||||
// utils/sessionSearchQuery. Null while the query carries no filter.
|
||||
const searchMatcher = useMemo(() => buildSessionSearchMatcher(searchQuery), [searchQuery]);
|
||||
|
||||
// Reset to page 1 whenever the displayed set or its ordering changes —
|
||||
// includes external scope changes (tenant switch, global-admin toggle, tenant filter).
|
||||
// React's adjust-during-render pattern (compare the previous key, set state while
|
||||
@@ -181,34 +190,23 @@ export function useDashboardFilters({
|
||||
if (operator === "<=") return durationMinutes <= value;
|
||||
}
|
||||
|
||||
const searchableText = [
|
||||
session.deviceName,
|
||||
session.serialNumber,
|
||||
session.manufacturer,
|
||||
session.model,
|
||||
session.status,
|
||||
session.sessionId,
|
||||
if (!searchMatcher) return true;
|
||||
|
||||
// Client-derived tokens only a free term can reach; the session fields themselves
|
||||
// are read by the matcher. Newline-joined so no term spans two tokens.
|
||||
const extra = [
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
`${Math.round((session.durationSeconds ?? 0) / 60)} min`,
|
||||
blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`) ? "blocked" : "",
|
||||
session.geoCountry,
|
||||
session.geoRegion,
|
||||
session.geoCity,
|
||||
session.agentVersion,
|
||||
session.osName,
|
||||
session.osBuild,
|
||||
session.osDisplayVersion,
|
||||
session.osEdition,
|
||||
session.osLanguage,
|
||||
// Tenant column is cross-tenant only; keep single-tenant search free of a value
|
||||
// every row shares.
|
||||
globalAdminMode ? session.tenantId : "",
|
||||
globalAdminMode ? tenantDomainById?.get(session.tenantId) : "",
|
||||
].join(" ").toLowerCase();
|
||||
globalAdminMode ? tenantDomainById?.get(session.tenantId) ?? "" : "",
|
||||
].join("\n");
|
||||
|
||||
return searchableText.includes(query);
|
||||
return searchMatcher(session, extra);
|
||||
});
|
||||
}, [effectiveSessions, ruleSessionIds, statusFilter, columnFilters, searchQuery, blockedDevicesSet, globalAdminMode, tenantDomainById]);
|
||||
}, [effectiveSessions, ruleSessionIds, statusFilter, columnFilters, searchQuery, searchMatcher, blockedDevicesSet, globalAdminMode, tenantDomainById]);
|
||||
|
||||
const sortedSessions = useMemo(() => {
|
||||
if (!sortColumn) return filteredSessions;
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import cases from "@/utils/session-search-syntax.cases.json";
|
||||
import {
|
||||
SESSION_SEARCH_FIELDS,
|
||||
buildSessionSearchMatcher,
|
||||
buildSessionSearchQuery,
|
||||
matchedSearchField,
|
||||
parseSessionSearchQuery,
|
||||
type SearchableSession,
|
||||
} from "../sessionSearchQuery";
|
||||
|
||||
const sample = cases.session as SearchableSession;
|
||||
|
||||
describe("session search grammar — shared parity cases", () => {
|
||||
it.each(cases.cases.map((c) => [c.query, c.expected, c.note, c.extra] as const))(
|
||||
"%j → %s (%s)",
|
||||
(query, expected, _note, extra) => {
|
||||
const matcher = buildSessionSearchMatcher(query);
|
||||
expect(matcher ? matcher(sample, extra) : true).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("names every searched field exactly once, so the case file covers the vocabulary", () => {
|
||||
const qualifiers = SESSION_SEARCH_FIELDS.map((f) => f.qualifier);
|
||||
expect(new Set(qualifiers).size).toBe(qualifiers.length);
|
||||
expect(new Set(SESSION_SEARCH_FIELDS.map((f) => f.key)).size).toBe(qualifiers.length);
|
||||
// Every qualifier appears in at least one case, so a renamed field breaks the file.
|
||||
const text = cases.cases.map((c) => c.query.toLowerCase()).join("\n");
|
||||
for (const q of qualifiers) expect(text, `no case uses ${q}=`).toMatch(new RegExp(`${q}[=:]`));
|
||||
// The sample session carries a value for every searched field.
|
||||
for (const f of SESSION_SEARCH_FIELDS) expect(sample[f.key], f.key).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSessionSearchQuery", () => {
|
||||
it("resolves known qualifiers to session keys and keeps unknown ones literal", () => {
|
||||
expect(parseSessionSearchQuery('Model=Surface -status:failed 14:30 foo=bar')).toEqual({
|
||||
include: [{ text: "surface", field: "model" }, { text: "14:30" }, { text: "foo=bar" }],
|
||||
exclude: [{ text: "failed", field: "status" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("de-duplicates terms by text and field", () => {
|
||||
expect(parseSessionSearchQuery("x model=x model=x x")).toEqual({
|
||||
include: [{ text: "x" }, { text: "x", field: "model" }],
|
||||
exclude: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionSearchMatcher", () => {
|
||||
it("returns null when nothing filters", () => {
|
||||
expect(buildSessionSearchMatcher("")).toBeNull();
|
||||
expect(buildSessionSearchMatcher(" - model= ")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a missing field value as empty", () => {
|
||||
const matcher = buildSessionSearchMatcher("model=x")!;
|
||||
expect(matcher({ ...sample, model: undefined as unknown as string })).toBe(false);
|
||||
expect(buildSessionSearchMatcher("-model=x")!({ ...sample, model: undefined as unknown as string })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchedSearchField", () => {
|
||||
it("prefers the field a qualifier names over an earlier free hit", () => {
|
||||
// "e" is in the device name (first field) but the qualifier says manufacturer.
|
||||
expect(matchedSearchField(parseSessionSearchQuery("e manufacturer=contoso"), sample)).toEqual({
|
||||
label: "Manufacturer",
|
||||
value: "Contoso",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the first field in priority order for a free term", () => {
|
||||
expect(matchedSearchField(parseSessionSearchQuery("2222"), sample)).toEqual({
|
||||
label: "Session ID",
|
||||
value: sample.sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when only client-derived text could have matched", () => {
|
||||
expect(matchedSearchField(parseSessionSearchQuery("blocked"), sample)).toBeNull();
|
||||
expect(matchedSearchField(parseSessionSearchQuery("-failed"), sample)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionSearchQuery", () => {
|
||||
it("quotes values with spaces or grammar characters and skips empty ones", () => {
|
||||
expect(buildSessionSearchQuery({ manufacturer: "Contoso", model: "EliteBook 840" })).toBe(
|
||||
'manufacturer=Contoso model="EliteBook 840"',
|
||||
);
|
||||
expect(buildSessionSearchQuery({ model: "X=1", manufacturer: " " })).toBe('model="X=1"');
|
||||
expect(buildSessionSearchQuery({ model: "B", manufacturer: "A" })).toBe("model=B manufacturer=A");
|
||||
expect(buildSessionSearchQuery({ manufacturer: null, model: undefined })).toBe("");
|
||||
});
|
||||
|
||||
it("drops double quotes — the grammar has no escape", () => {
|
||||
expect(buildSessionSearchQuery({ model: 'Tablet 10"' })).toBe('model="Tablet 10"');
|
||||
});
|
||||
|
||||
it("round-trips through the parser", () => {
|
||||
const query = buildSessionSearchQuery({ manufacturer: "Contoso", model: "EliteBook 840" });
|
||||
expect(buildSessionSearchMatcher(query)!(sample)).toBe(true);
|
||||
expect(buildSessionSearchMatcher(query)!({ ...sample, manufacturer: "Fabrikam" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// Search syntax for the session list's search box.
|
||||
//
|
||||
// Same grammar as the event timeline (lib/searchQueryTokens): whitespace-separated
|
||||
// terms are AND-ed, a leading minus excludes, quotes protect a phrase — plus field
|
||||
// qualifiers, because "Contoso" and "EliteBook 840" live in two different columns and
|
||||
// a phrase can never match across a field boundary:
|
||||
//
|
||||
// surface substring in any searched field
|
||||
// surface failed both terms, each in any field
|
||||
// model=surface only the model field
|
||||
// manufacturer="Contoso Ltd" phrase, only the manufacturer field
|
||||
// model="EliteBook 840" -failed combined with an exclusion
|
||||
// "a=b" quoted: a literal, not a qualifier
|
||||
//
|
||||
// A qualifier that is not in SESSION_SEARCH_FIELDS stays a literal term (`14:30`,
|
||||
// `foo=bar`). Qualified terms are substring matches like every other term — exact
|
||||
// matching is the column filter's job. The backend parses the SAME grammar for the
|
||||
// server-side sweep (`q=` on /api/search/sessions, SessionSearchQuery.cs); both sides
|
||||
// run utils/session-search-syntax.cases.json, so a term that matches here matches there.
|
||||
|
||||
import type { Session } from "../types";
|
||||
import { literalText, tokenizeSearchQuery } from "@/lib/searchQueryTokens";
|
||||
|
||||
export interface SessionSearchField {
|
||||
/** The word in front of `=` — lowercase, one per field. */
|
||||
readonly qualifier: string;
|
||||
readonly key: keyof Session;
|
||||
/** Column label, also shown as "Matched: …" in the suggestion list. */
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The searched fields, in suggestion priority order. Exactly the set the backend's
|
||||
* free-text predicate searches — a field only one side knows would produce ghost
|
||||
* results (server finds it, client filters it back out) or the reverse.
|
||||
*/
|
||||
export const SESSION_SEARCH_FIELDS = [
|
||||
{ qualifier: "device", key: "deviceName", label: "Device" },
|
||||
{ qualifier: "serial", key: "serialNumber", label: "Serial" },
|
||||
{ qualifier: "model", key: "model", label: "Model" },
|
||||
{ qualifier: "manufacturer", key: "manufacturer", label: "Manufacturer" },
|
||||
{ qualifier: "status", key: "status", label: "Status" },
|
||||
{ qualifier: "session", key: "sessionId", label: "Session ID" },
|
||||
{ qualifier: "country", key: "geoCountry", label: "Country" },
|
||||
{ qualifier: "region", key: "geoRegion", label: "Region" },
|
||||
{ qualifier: "city", key: "geoCity", label: "City" },
|
||||
{ qualifier: "agent", key: "agentVersion", label: "Agent Version" },
|
||||
{ qualifier: "os", key: "osName", label: "OS Name" },
|
||||
{ qualifier: "build", key: "osBuild", label: "OS Build" },
|
||||
{ qualifier: "osversion", key: "osDisplayVersion", label: "OS Version" },
|
||||
{ qualifier: "edition", key: "osEdition", label: "OS Edition" },
|
||||
{ qualifier: "language", key: "osLanguage", label: "OS Language" },
|
||||
] as const satisfies readonly SessionSearchField[];
|
||||
|
||||
export type SessionSearchQualifier = (typeof SESSION_SEARCH_FIELDS)[number]["qualifier"];
|
||||
export type SessionSearchKey = (typeof SESSION_SEARCH_FIELDS)[number]["key"];
|
||||
|
||||
/** The slice of a session the matcher reads. */
|
||||
export type SearchableSession = Pick<Session, SessionSearchKey>;
|
||||
|
||||
export interface SessionSearchTerm {
|
||||
/** Lowercased. */
|
||||
text: string;
|
||||
/** Set for a qualified term: the one field it may match. */
|
||||
field?: SessionSearchKey;
|
||||
}
|
||||
|
||||
export interface ParsedSessionSearchQuery {
|
||||
include: SessionSearchTerm[];
|
||||
exclude: SessionSearchTerm[];
|
||||
}
|
||||
|
||||
const KEY_BY_QUALIFIER: ReadonlyMap<string, SessionSearchKey> = new Map(
|
||||
SESSION_SEARCH_FIELDS.map((f) => [f.qualifier, f.key]),
|
||||
);
|
||||
|
||||
export function parseSessionSearchQuery(query: string): ParsedSessionSearchQuery {
|
||||
const include: SessionSearchTerm[] = [];
|
||||
const exclude: SessionSearchTerm[] = [];
|
||||
|
||||
for (const token of tokenizeSearchQuery(query)) {
|
||||
const field = token.qualifier === undefined ? undefined : KEY_BY_QUALIFIER.get(token.qualifier.toLowerCase());
|
||||
const term: SessionSearchTerm = field
|
||||
? { text: token.text.toLowerCase(), field }
|
||||
: { text: literalText(token).toLowerCase() };
|
||||
const target = token.negated ? exclude : include;
|
||||
if (!target.some((t) => t.text === term.text && t.field === term.field)) target.push(term);
|
||||
}
|
||||
|
||||
return { include, exclude };
|
||||
}
|
||||
|
||||
function fieldText(session: SearchableSession, key: SessionSearchKey): string {
|
||||
return String(session[key] ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one term hits the session. `extra` is client-derived text (formatted date,
|
||||
* "N min", "blocked", tenant domain) that only a free term may match — newline-joined by
|
||||
* the caller, so a term can never span two of those tokens either.
|
||||
*/
|
||||
function termMatches(term: SessionSearchTerm, session: SearchableSession, extra?: string): boolean {
|
||||
if (term.field) return fieldText(session, term.field).includes(term.text);
|
||||
return (
|
||||
SESSION_SEARCH_FIELDS.some((f) => fieldText(session, f.key).includes(term.text)) ||
|
||||
(extra !== undefined && extra.toLowerCase().includes(term.text))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate for the query, or null when it carries no filter (empty, whitespace, a
|
||||
* lone minus or a bare `model=` while the user is still typing).
|
||||
*/
|
||||
export function buildSessionSearchMatcher(
|
||||
query: string,
|
||||
): ((session: SearchableSession, extra?: string) => boolean) | null {
|
||||
const { include, exclude } = parseSessionSearchQuery(query);
|
||||
if (include.length === 0 && exclude.length === 0) return null;
|
||||
|
||||
return (session, extra) =>
|
||||
include.every((t) => termMatches(t, session, extra)) &&
|
||||
!exclude.some((t) => termMatches(t, session, extra));
|
||||
}
|
||||
|
||||
/**
|
||||
* For the suggestion list: the first searched field an include term hits, qualified
|
||||
* terms first (they name the field the user meant). Null when only `extra` matched
|
||||
* (the caller labels that itself) or when the query has no include terms.
|
||||
*/
|
||||
export function matchedSearchField(
|
||||
parsed: ParsedSessionSearchQuery,
|
||||
session: SearchableSession,
|
||||
): { label: string; value: string } | null {
|
||||
const ordered = [...parsed.include.filter((t) => t.field), ...parsed.include.filter((t) => !t.field)];
|
||||
for (const term of ordered) {
|
||||
for (const f of SESSION_SEARCH_FIELDS) {
|
||||
if (term.field && term.field !== f.key) continue;
|
||||
const value = session[f.key];
|
||||
if (value != null && String(value).toLowerCase().includes(term.text)) {
|
||||
return { label: f.label, value: String(value) };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a query of qualified terms in the caller's order — the deep link from Fleet
|
||||
* Health's model rows. Empty values are skipped; a value with whitespace or a grammar
|
||||
* character is quoted (double quotes inside it are dropped: the grammar has no escape).
|
||||
*/
|
||||
export function buildSessionSearchQuery(
|
||||
parts: Partial<Record<SessionSearchQualifier, string | null | undefined>>,
|
||||
): string {
|
||||
const terms: string[] = [];
|
||||
for (const [qualifier, raw] of Object.entries(parts)) {
|
||||
const value = raw?.trim().replace(/"/g, "");
|
||||
if (!value || !KEY_BY_QUALIFIER.has(qualifier)) continue;
|
||||
terms.push(`${qualifier}=${/[\s=:]/.test(value) ? `"${value}"` : value}`);
|
||||
}
|
||||
return terms.join(" ");
|
||||
}
|
||||
@@ -6,63 +6,12 @@ import { useLatest } from "@/hooks/useLatest";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
import { ApiError, fetchJson } from "@/lib/apiClient";
|
||||
|
||||
// Wire shape of the server-aggregated Fleet Health payload (camelCase of the
|
||||
// backend FleetHealthMetrics DTO). Presentation-only derivations (bar maxima,
|
||||
// axis labels) stay in the page.
|
||||
export interface FleetHealthStats {
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
inProgress: number;
|
||||
/** Terminal, non-failure (timeout reclassification). Surfaced separately; not counted as a failure. */
|
||||
incomplete: number;
|
||||
/** Succeeded / (succeeded + failed) — finished enrollments only. 0 when nothing finished yet. */
|
||||
successRate: number;
|
||||
avgDurationMinutes: number;
|
||||
/** Median over the same population as avg — the card's headline value (outlier-robust). */
|
||||
medianDurationMinutes: number;
|
||||
/** 90th percentile — the tail signal the median alone would hide. */
|
||||
p90DurationMinutes: number;
|
||||
}
|
||||
export interface FleetDailyPoint {
|
||||
date: string;
|
||||
success: number;
|
||||
failed: number;
|
||||
}
|
||||
export interface FleetFailureReason {
|
||||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
export interface FleetModelHealth {
|
||||
model: string;
|
||||
/** All sessions on this model in the window, including in-flight ones. */
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
}
|
||||
export interface FleetSlowModel {
|
||||
model: string;
|
||||
avgMinutes: number;
|
||||
count: number;
|
||||
}
|
||||
export interface FleetFailingModel {
|
||||
model: string;
|
||||
failed: number;
|
||||
total: number;
|
||||
failureRate: number;
|
||||
}
|
||||
export interface FleetHealthData {
|
||||
success: boolean;
|
||||
days: number;
|
||||
stats: FleetHealthStats;
|
||||
dailyData: FleetDailyPoint[];
|
||||
failureReasons: FleetFailureReason[];
|
||||
modelHealth: FleetModelHealth[];
|
||||
slowestModels: FleetSlowModel[];
|
||||
topFailingModels: FleetFailingModel[];
|
||||
computedAt: string;
|
||||
}
|
||||
// Wire shape of the server-aggregated Fleet Health payload: the generated mirror of the
|
||||
// backend FleetHealthMetrics DTO. Presentation-only derivations (bar maxima, axis
|
||||
// labels) stay in the page.
|
||||
import type { FleetHealthMetrics } from "@/utils/wire-types.generated";
|
||||
|
||||
export type FleetHealthData = FleetHealthMetrics;
|
||||
|
||||
interface SignalRApi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { dashboardUrl } from "@/lib/routes";
|
||||
import { buildSessionSearchQuery } from "../dashboard/utils/sessionSearchQuery";
|
||||
import type { FleetModelHealth } from "@/utils/wire-types.generated";
|
||||
import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import { useSignalR } from "../../contexts/SignalRContext";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
@@ -175,15 +177,20 @@ export default function FleetHealthPage() {
|
||||
const slowestModels = data?.slowestModels ?? [];
|
||||
|
||||
// Deep-link a model bucket into the dashboard, pre-filtered to Failed sessions of that
|
||||
// model. The model key is "{Manufacturer} {Model}", which the dashboard search matches
|
||||
// against its combined manufacturer+model text. Carry the selected tenant so a global
|
||||
// admin scoped to one tenant lands on that tenant's list rather than their default scope.
|
||||
const dashboardModelHref = (model: string): Route =>
|
||||
dashboardUrl({
|
||||
// model. Manufacturer and model are two session fields, so the search carries them as
|
||||
// two qualified terms (`manufacturer=… model=…`) rather than one phrase that could
|
||||
// never match across the field boundary. Null for the "Unknown" bucket (both blank):
|
||||
// there is nothing to search for. Carry the selected tenant so a global admin scoped
|
||||
// to one tenant lands on that tenant's list rather than their default scope.
|
||||
const dashboardModelHref = (m: FleetModelHealth): Route | null => {
|
||||
const search = buildSessionSearchQuery({ manufacturer: m.manufacturer, model: m.model });
|
||||
if (!search) return null;
|
||||
return dashboardUrl({
|
||||
status: "Failed",
|
||||
search: model,
|
||||
search,
|
||||
tenant: isGlobalAdmin && selectedTenantId ? selectedTenantId : undefined,
|
||||
});
|
||||
};
|
||||
const topFailingModels = data?.topFailingModels ?? [];
|
||||
// Memoized (unlike the sibling defaults above) because it feeds the maxFailureCount
|
||||
// useMemo below — a fresh [] each render would invalidate that memo every time.
|
||||
@@ -483,14 +490,14 @@ export default function FleetHealthPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{slowestModels.map((m, i) => (
|
||||
<div key={m.model} className="flex items-center space-x-3">
|
||||
<div key={m.label} className="flex items-center space-x-3">
|
||||
<span className="text-xs text-gray-400 w-4">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<TruncatedLabel
|
||||
text={m.model}
|
||||
text={m.label}
|
||||
className="text-sm text-gray-700 pr-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900 flex-shrink-0">
|
||||
@@ -519,14 +526,14 @@ export default function FleetHealthPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{topFailingModels.map((m, i) => (
|
||||
<div key={m.model} className="flex items-center space-x-3">
|
||||
<div key={m.label} className="flex items-center space-x-3">
|
||||
<span className="text-xs text-gray-400 w-4">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<TruncatedLabel
|
||||
text={m.model}
|
||||
text={m.label}
|
||||
className="text-sm text-gray-700 pr-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-red-600 flex-shrink-0">
|
||||
@@ -711,15 +718,11 @@ export default function FleetHealthPage() {
|
||||
finished > 0
|
||||
? Math.round((m.succeeded / finished) * 100)
|
||||
: null;
|
||||
return (
|
||||
<Link
|
||||
key={m.model}
|
||||
href={dashboardModelHref(m.model)}
|
||||
className="block -mx-2 px-2 py-1 rounded-md hover:bg-gray-50 transition-colors group cursor-pointer"
|
||||
title={`Show failed enrollments for ${m.model}`}
|
||||
>
|
||||
const href = dashboardModelHref(m);
|
||||
const row = (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between mb-1">
|
||||
<span className="text-sm text-gray-700 group-hover:text-green-600 break-words leading-snug">{m.model}</span>
|
||||
<span className="text-sm text-gray-700 group-hover:text-green-600 break-words leading-snug">{m.label}</span>
|
||||
<span className="ml-3 flex-shrink-0 text-sm font-medium text-gray-900">
|
||||
{successRate !== null ? `${successRate}%` : "—"} <span className="text-xs font-normal text-gray-400">({m.total} devices)</span>
|
||||
</span>
|
||||
@@ -738,7 +741,22 @@ export default function FleetHealthPage() {
|
||||
style={{ width: `${successRate ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
// The "Unknown" bucket (no manufacturer, no model) has nothing to search for.
|
||||
return href ? (
|
||||
<Link
|
||||
key={m.label}
|
||||
href={href}
|
||||
className="block -mx-2 px-2 py-1 rounded-md hover:bg-gray-50 transition-colors group cursor-pointer"
|
||||
title={`Show failed enrollments for ${m.label}`}
|
||||
>
|
||||
{row}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={m.label} className="-mx-2 px-2 py-1">
|
||||
{row}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
+24
-4
@@ -84,11 +84,11 @@ describe("buildScopeFields", () => {
|
||||
it("sends the selected scope and emit mode for a repeating trigger", () => {
|
||||
expect(buildScopeFields(form({
|
||||
trigger: "interval", scopeMode: "from", activeFromPhase: "AccountSetup", emitMode: "on_change",
|
||||
}))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: "on_change" });
|
||||
}))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: "on_change", enrichErrorCodes: false });
|
||||
|
||||
expect(buildScopeFields(form({
|
||||
trigger: "interval", scopeMode: "during", activePhases: ["DeviceSetup", "AppsDevice"], emitMode: "always",
|
||||
}))).toEqual({ activePhases: ["DeviceSetup", "AppsDevice"], activeFromPhase: null, emitMode: "always" });
|
||||
}))).toEqual({ activePhases: ["DeviceSetup", "AppsDevice"], activeFromPhase: null, emitMode: "always", enrichErrorCodes: false });
|
||||
});
|
||||
|
||||
it("drops hidden-control state instead of leaking it into the payload", () => {
|
||||
@@ -103,14 +103,23 @@ describe("buildScopeFields", () => {
|
||||
});
|
||||
|
||||
expect(buildScopeFields(switched)).toEqual({
|
||||
activePhases: null, activeFromPhase: null, emitMode: null,
|
||||
activePhases: null, activeFromPhase: null, emitMode: null, enrichErrorCodes: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a deferring scope on a startup rule but drops its emit mode", () => {
|
||||
expect(buildScopeFields(form({
|
||||
trigger: "startup", scopeMode: "from", activeFromPhase: "AccountSetup", emitMode: "on_change",
|
||||
}))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: null });
|
||||
}))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: null, enrichErrorCodes: false });
|
||||
});
|
||||
|
||||
it("sends the error-code opt-in for every trigger, and only as a strict boolean", () => {
|
||||
// Enrichment is about the payload, not the cadence — no trigger hides it.
|
||||
expect(buildScopeFields(form({ trigger: "startup", enrichErrorCodes: true })).enrichErrorCodes).toBe(true);
|
||||
expect(buildScopeFields(form({ trigger: "phase_change", triggerPhase: "AccountSetup", enrichErrorCodes: true })).enrichErrorCodes).toBe(true);
|
||||
expect(buildScopeFields(form({ trigger: "interval", enrichErrorCodes: true })).enrichErrorCodes).toBe(true);
|
||||
// A JSON-mode paste can carry anything; the backend gets a real boolean.
|
||||
expect(buildScopeFields(form({ enrichErrorCodes: "true" as unknown as boolean })).enrichErrorCodes).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an unfilled scope mode as unrestricted", () => {
|
||||
@@ -130,6 +139,12 @@ describe("withDerivedScopeMode", () => {
|
||||
expect(withDerivedScopeMode(form({ emitMode: "on_change" })).emitMode).toBe("on_change");
|
||||
});
|
||||
|
||||
it("coerces the error-code opt-in to a strict boolean", () => {
|
||||
expect(withDerivedScopeMode(form({ enrichErrorCodes: true })).enrichErrorCodes).toBe(true);
|
||||
expect(withDerivedScopeMode({ ...EMPTY_FORM, enrichErrorCodes: undefined } as unknown as NewRuleForm).enrichErrorCodes).toBe(false);
|
||||
expect(withDerivedScopeMode({ ...EMPTY_FORM, enrichErrorCodes: "yes" } as unknown as NewRuleForm).enrichErrorCodes).toBe(false);
|
||||
});
|
||||
|
||||
it("survives non-array activePhases from hand-written JSON", () => {
|
||||
const parsed = { ...EMPTY_FORM, activePhases: undefined } as unknown as NewRuleForm;
|
||||
expect(withDerivedScopeMode(parsed).activePhases).toEqual([]);
|
||||
@@ -166,6 +181,8 @@ describe("gatherRuleToForm", () => {
|
||||
// A rule without emitMode behaves "always" — the import must not upgrade it to
|
||||
// the empty-form on_change default.
|
||||
expect(form.emitMode).toBe("always");
|
||||
// A rule without enrichErrorCodes (older export) stays opted out.
|
||||
expect(form.enrichErrorCodes).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers the parameters object over stale flat fields on an edit merge", () => {
|
||||
@@ -213,6 +230,7 @@ describe("gatherRuleToForm", () => {
|
||||
intervalSeconds: 300,
|
||||
activePhases: ["DeviceSetup", "AppsDevice"],
|
||||
emitMode: "on_change",
|
||||
enrichErrorCodes: true,
|
||||
outputEventType: "battery_status",
|
||||
outputSeverity: "info",
|
||||
tags: ["power"],
|
||||
@@ -223,10 +241,12 @@ describe("gatherRuleToForm", () => {
|
||||
expect(form.activePhases).toEqual(["DeviceSetup", "AppsDevice"]);
|
||||
expect(form.intervalSeconds).toBe(300);
|
||||
expect(form.emitMode).toBe("on_change");
|
||||
expect(form.enrichErrorCodes).toBe(true);
|
||||
expect(buildScopeFields(form)).toEqual({
|
||||
activePhases: ["DeviceSetup", "AppsDevice"],
|
||||
activeFromPhase: null,
|
||||
emitMode: "on_change",
|
||||
enrichErrorCodes: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,8 +87,8 @@ export function GatherRuleCard({
|
||||
onExpand();
|
||||
}}
|
||||
>
|
||||
{/* Mobile: badges wrap and the title drops onto its own full-width row (order-last);
|
||||
≥sm: single line with the title in the middle, exactly as before. */}
|
||||
{/* Two rows on every breakpoint: the badge row on top, the title on its own full-width
|
||||
row below. Toggle and rule id have fixed minimum widths so every card lines up. */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
{/* Enable/Disable Toggle */}
|
||||
{readOnly ? (
|
||||
@@ -122,28 +122,17 @@ export function GatherRuleCard({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Rule ID */}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-medium bg-gray-100 text-gray-600 border border-gray-200 flex-shrink-0 hidden sm:inline-flex">
|
||||
{/* Rule ID — fixed-width column (widest catalog id has 17 characters); a longer
|
||||
custom id overflows its column instead of being cut */}
|
||||
<span className="inline-flex items-center min-w-[9rem] px-2 py-0.5 rounded text-xs font-mono font-medium bg-gray-100 text-gray-600 border border-gray-200 flex-shrink-0 hidden sm:inline-flex">
|
||||
{rule.ruleId}
|
||||
</span>
|
||||
|
||||
{/* Title */}
|
||||
<div className="order-last w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
|
||||
<h3 className="text-sm font-semibold text-gray-900 sm:truncate">
|
||||
{rule.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Category Badge */}
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${catColor.bg} ${catColor.text} flex-shrink-0`}>
|
||||
{rule.category.charAt(0).toUpperCase() + rule.category.slice(1)}
|
||||
</span>
|
||||
|
||||
{/* Collector Type */}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-600 flex-shrink-0 hidden md:inline-flex">
|
||||
{COLLECTOR_TYPE_LABELS[rule.collectorType] || rule.collectorType}
|
||||
</span>
|
||||
|
||||
{/* Type Badge */}
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${
|
||||
@@ -157,6 +146,11 @@ export function GatherRuleCard({
|
||||
{rule.isBuiltIn ? "Built-in" : rule.isCommunity ? "Community" : "Custom"}
|
||||
</span>
|
||||
|
||||
{/* Collector Type */}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-600 flex-shrink-0 hidden md:inline-flex">
|
||||
{COLLECTOR_TYPE_LABELS[rule.collectorType] || rule.collectorType}
|
||||
</span>
|
||||
|
||||
{/* Enabled rule the agent blocks on every device — visible without expanding */}
|
||||
{rule.enabled && isBlockedTarget && (
|
||||
<span
|
||||
@@ -167,9 +161,9 @@ export function GatherRuleCard({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Expand/Collapse Arrow */}
|
||||
{/* Expand/Collapse Arrow — right edge on every card */}
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto sm:ml-0 ${
|
||||
className={`w-5 h-5 text-gray-400 transition-transform flex-shrink-0 ml-auto ${
|
||||
isExpanded ? "rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
@@ -178,6 +172,13 @@ export function GatherRuleCard({
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
|
||||
{/* Title row */}
|
||||
<div className="w-full min-w-0">
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{rule.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -381,6 +382,13 @@ export function GatherRuleCard({
|
||||
{rule.outputSeverity.charAt(0).toUpperCase() + rule.outputSeverity.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
{rule.enrichErrorCodes && (
|
||||
<div title="exitCode / errorCode / hresult fields in this rule's output are explained with the Windows/MSI/Intune error-code catalog">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700">
|
||||
Error codes explained
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -663,6 +663,26 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output: error-code catalog opt-in. Off by default — gather-rule codes usually follow a
|
||||
vendor's own numbering (HPiA, DCU, custom scripts), where the Windows/MSI meaning is wrong. */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enrichErrorCodes}
|
||||
onChange={(e) => setForm({ ...form, enrichErrorCodes: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Explain error codes with the Windows catalog</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mt-1 ml-6">
|
||||
Attaches the Windows/MSI/Intune meaning to <code className="px-1 bg-gray-100 rounded">exitCode</code>,{" "}
|
||||
<code className="px-1 bg-gray-100 rounded">errorCode</code> and <code className="px-1 bg-gray-100 rounded">hresult</code> fields
|
||||
in this rule's output, like on built-in events. Leave off when the source uses its own exit codes
|
||||
(HP Image Assistant, Dell Command Update, custom scripts) — the catalog would explain them wrongly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface GatherRule {
|
||||
activePhases?: string[] | null;
|
||||
activeFromPhase?: string | null;
|
||||
emitMode?: string | null;
|
||||
enrichErrorCodes?: boolean;
|
||||
outputEventType: string;
|
||||
outputSeverity: string;
|
||||
tags: string[];
|
||||
@@ -57,6 +58,7 @@ export interface NewRuleForm {
|
||||
activePhases: string[];
|
||||
activeFromPhase: string;
|
||||
emitMode: string;
|
||||
enrichErrorCodes: boolean;
|
||||
outputEventType: string;
|
||||
outputSeverity: string;
|
||||
tags: string[];
|
||||
@@ -172,6 +174,7 @@ export const EMPTY_FORM: NewRuleForm = {
|
||||
activeFromPhase: "",
|
||||
// New rules default to on_change (anti-spam); existing rules load as "always" in startEditing.
|
||||
emitMode: "on_change",
|
||||
enrichErrorCodes: false,
|
||||
outputEventType: "",
|
||||
outputSeverity: "info",
|
||||
tags: [],
|
||||
@@ -253,6 +256,7 @@ export function buildScopeFields(form: NewRuleForm) {
|
||||
activePhases: scoped && form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null,
|
||||
activeFromPhase: scoped && form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null,
|
||||
emitMode: supportsEmitMode(form) ? (form.emitMode || null) : null,
|
||||
enrichErrorCodes: form.enrichErrorCodes === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,7 +273,8 @@ export function withDerivedScopeMode(form: NewRuleForm): NewRuleForm {
|
||||
const scopeMode: NewRuleForm["scopeMode"] =
|
||||
activePhases.length > 0 ? "during" : activeFromPhase ? "from" : "always";
|
||||
const emitMode = form.emitMode === "on_change" ? "on_change" : "always";
|
||||
return { ...form, activePhases, activeFromPhase, scopeMode, emitMode };
|
||||
const enrichErrorCodes = form.enrichErrorCodes === true;
|
||||
return { ...form, activePhases, activeFromPhase, scopeMode, emitMode, enrichErrorCodes };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,6 +327,7 @@ export function gatherRuleToForm(input: PastedGatherJson): NewRuleForm {
|
||||
// A rule without the field behaves "always"; only an explicit on_change survives —
|
||||
// the on_change default is for rules started from the empty form, not for imports.
|
||||
emitMode: input.emitMode ?? "always",
|
||||
enrichErrorCodes: input.enrichErrorCodes === true,
|
||||
outputEventType: input.outputEventType ?? "",
|
||||
outputSeverity: input.outputSeverity ?? EMPTY_FORM.outputSeverity,
|
||||
tags: Array.isArray(input.tags) ? input.tags.filter((t): t is string => typeof t === "string") : [],
|
||||
|
||||
@@ -134,7 +134,10 @@ export default function GeoMap({
|
||||
<strong>Sessions:</strong> {loc.sessionCount}
|
||||
{loc.succeeded + loc.failed > 0 ? ` (${loc.successRate}% success)` : " (none finished yet)"}
|
||||
</div>
|
||||
<div><strong>Avg Duration:</strong> {Math.round(loc.avgDurationMinutes)} min</div>
|
||||
<div>
|
||||
<strong>Avg Duration:</strong>{" "}
|
||||
{loc.avgDurationMinutes > 0 ? `${Math.round(loc.avgDurationMinutes)} min` : "no succeeded enrollment yet"}
|
||||
</div>
|
||||
{loc.medianApiLatencyMs > 0 && (
|
||||
<div><strong>API Latency:</strong> {Math.round(loc.medianApiLatencyMs)} ms</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DocsLink } from "@/components/DocsLink";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
|
||||
/**
|
||||
* What the duration columns of the Location Performance table are computed over. Mirrors the
|
||||
* backend aggregation (succeeded sessions per location; the benchmark line is the reader's own
|
||||
* fleet) so a reader never has to guess whether "vs Global" is avg, median or P95 based, or
|
||||
* whether "global" means other customers.
|
||||
*/
|
||||
export const LOCATION_TABLE_NOTE = {
|
||||
duration: "Avg Duration and P95 = enrollment duration of the succeeded sessions at the location in the selected range",
|
||||
vsGlobal: "vs Global = Avg Duration against the benchmark line above (your own fleet, same range; positive = slower)",
|
||||
} as const;
|
||||
|
||||
export function LocationTableNote() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-gray-200 px-4 py-2.5 text-xs text-gray-500">
|
||||
<span>{LOCATION_TABLE_NOTE.duration}</span>
|
||||
<span className="text-gray-300" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span>{LOCATION_TABLE_NOTE.vsGlobal}</span>
|
||||
<DocsLink path={DOCS_PATHS.geographicPerformanceNumbers} label="How the numbers are calculated" />
|
||||
<DocsLink path={DOCS_PATHS.statistics} label="Averages, medians & percentiles" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { LocationTableNote, LOCATION_TABLE_NOTE } from "../LocationTableNote";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
describe("LocationTableNote", () => {
|
||||
const html = renderToStaticMarkup(createElement(LocationTableNote));
|
||||
|
||||
it("states the population behind the duration columns and the basis of vs Global", () => {
|
||||
expect(LOCATION_TABLE_NOTE.duration).toMatch(/succeeded/i);
|
||||
expect(LOCATION_TABLE_NOTE.duration).toMatch(/P95/);
|
||||
expect(LOCATION_TABLE_NOTE.vsGlobal).toMatch(/Avg Duration/);
|
||||
expect(LOCATION_TABLE_NOTE.vsGlobal).toMatch(/own fleet/i);
|
||||
expect(LOCATION_TABLE_NOTE.vsGlobal).toMatch(/positive = slower/i);
|
||||
expect(html).toContain(LOCATION_TABLE_NOTE.duration);
|
||||
expect(html).toContain(LOCATION_TABLE_NOTE.vsGlobal);
|
||||
});
|
||||
|
||||
it("links the calculation section of the geo guide and the statistics concept page", () => {
|
||||
expect(html).toContain(`href="${DOCS_URL}${DOCS_PATHS.geographicPerformanceNumbers}"`);
|
||||
expect(html).toContain(`href="${DOCS_URL}${DOCS_PATHS.statistics}"`);
|
||||
expect(DOCS_PATHS.geographicPerformanceNumbers).toMatch(/#how-the-numbers-are-calculated$/);
|
||||
expect(DOCS_PATHS.statistics).toBe("/concepts/averages-medians-and-percentiles");
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -143,6 +143,9 @@ describe("duration mode (ratio to global average)", () => {
|
||||
it("is no-data without a global average", () => {
|
||||
expect(duration.resolve(makeLoc(), makeGlobal({ avgDurationMinutes: 0 }))).toBe(duration.buckets[5]);
|
||||
});
|
||||
it("is no-data for a location without a succeeded session, never the fastest bucket", () => {
|
||||
expect(at(0)).toBe(duration.buckets[5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("success mode (finished enrollments only)", () => {
|
||||
@@ -221,7 +224,7 @@ describe("badge class parity", () => {
|
||||
"≤ 120% | bg-yellow-50 text-yellow-700",
|
||||
"≤ 150% | bg-orange-50 text-orange-700",
|
||||
"> 150% | bg-red-100 text-red-800",
|
||||
"No global average yet | bg-gray-100 text-gray-500",
|
||||
"No duration to measure | bg-gray-100 text-gray-500",
|
||||
],
|
||||
"latency": [
|
||||
"< 250 ms | bg-green-100 text-green-800",
|
||||
|
||||
@@ -56,7 +56,7 @@ const DURATION_BUCKETS = [
|
||||
{ label: "≤ 120%", hex: HEX.yellow, className: "bg-yellow-50 text-yellow-700" },
|
||||
{ label: "≤ 150%", hex: HEX.orange, className: "bg-orange-50 text-orange-700" },
|
||||
{ label: "> 150%", hex: HEX.red, className: "bg-red-100 text-red-800" },
|
||||
noData("No global average yet"),
|
||||
noData("No duration to measure"),
|
||||
] as const satisfies readonly ColorBucket[];
|
||||
|
||||
const duration: MapColorMode = {
|
||||
@@ -64,7 +64,9 @@ const duration: MapColorMode = {
|
||||
label: "Enrollment duration",
|
||||
buckets: DURATION_BUCKETS,
|
||||
resolve: (loc, global) => {
|
||||
if (global.avgDurationMinutes <= 0) return DURATION_BUCKETS[5];
|
||||
// Durations come from succeeded sessions only: a location where nothing succeeded yet has
|
||||
// avg 0, and 0 / global would land it in the fastest bucket instead of "nothing to measure".
|
||||
if (global.avgDurationMinutes <= 0 || loc.avgDurationMinutes <= 0) return DURATION_BUCKETS[5];
|
||||
const ratio = loc.avgDurationMinutes / global.avgDurationMinutes;
|
||||
if (ratio <= 0.8) return DURATION_BUCKETS[0];
|
||||
if (ratio <= 1.0) return DURATION_BUCKETS[1];
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type MapColorModeId,
|
||||
} from "./mapColorModes";
|
||||
import { MapLegend } from "./MapLegend";
|
||||
import { LocationTableNote } from "./LocationTableNote";
|
||||
import { fetchJson } from "@/lib/apiClient";
|
||||
|
||||
// A cross-tenant geo aggregation can take tens of seconds server-side; the default 30s fetch
|
||||
@@ -315,13 +316,16 @@ export default function GeographicPerformancePage() {
|
||||
{/* Global Averages Banner */}
|
||||
{geoMetrics && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="text-sm font-medium text-blue-800 mb-2">Global Averages (Benchmark)</div>
|
||||
<div className="text-sm font-medium text-blue-800">Global Averages (Benchmark)</div>
|
||||
<div className="text-xs text-blue-700 mb-2">
|
||||
Computed over the sessions on this page, not over other customers — every vs Global figure compares against this line.
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 text-sm">
|
||||
<div>
|
||||
<div title="Average of the per-location Avg Duration values (locations with 3+ sessions) — every location counts once, so one large site does not set the benchmark">
|
||||
<span className="text-blue-600 font-medium">Avg Duration:</span>{" "}
|
||||
<span className="text-blue-900">{geoMetrics.globalAverages.avgDurationMinutes} min</span>
|
||||
</div>
|
||||
<div>
|
||||
<div title="Median of the per-location Avg Duration values — half of your locations are faster, half slower">
|
||||
<span className="text-blue-600 font-medium">Median:</span>{" "}
|
||||
<span className="text-blue-900">{geoMetrics.globalAverages.medianDurationMinutes} min</span>
|
||||
</div>
|
||||
@@ -383,10 +387,14 @@ export default function GeographicPerformancePage() {
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgDurationMinutes")}
|
||||
title="Average enrollment duration of the succeeded sessions at this location in the selected range"
|
||||
>
|
||||
Avg Duration <SortIcon col="avgDurationMinutes" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
title="95th percentile: 95 out of 100 succeeded enrollments at this location finished within this time"
|
||||
>
|
||||
P95
|
||||
</th>
|
||||
<th
|
||||
@@ -414,7 +422,10 @@ export default function GeographicPerformancePage() {
|
||||
>
|
||||
P2P % <SortIcon col="avgDoPercentPeerCaching" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
title="Avg Duration of this location against the benchmark's Avg Duration (your own fleet, same range) — positive = slower, negative = faster"
|
||||
>
|
||||
vs Global
|
||||
</th>
|
||||
</tr>
|
||||
@@ -479,8 +490,9 @@ export default function GeographicPerformancePage() {
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${MAP_COLOR_MODE_BY_ID.duration.resolve(loc, geoMetrics.globalAverages).className}`}
|
||||
title={loc.avgDurationMinutes > 0 ? undefined : "No succeeded enrollments yet"}
|
||||
>
|
||||
{Math.round(loc.avgDurationMinutes)} min
|
||||
{loc.avgDurationMinutes > 0 ? `${Math.round(loc.avgDurationMinutes)} min` : "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
@@ -531,7 +543,7 @@ export default function GeographicPerformancePage() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
{loc.durationVsGlobalPct !== 0 ? (
|
||||
{loc.avgDurationMinutes > 0 && loc.durationVsGlobalPct !== 0 ? (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
loc.durationVsGlobalPct > 0
|
||||
@@ -556,6 +568,7 @@ export default function GeographicPerformancePage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{sortedLocations.length > 0 && <LocationTableNote />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -896,6 +896,26 @@ html:hover ::-webkit-scrollbar-thumb {
|
||||
animation: lpFadeUp 0.4s ease-out both;
|
||||
}
|
||||
|
||||
/* What's new flyout: slides in from the right edge; unmounted on close, so no exit animation. */
|
||||
@keyframes whatsNewSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.whats-new-card {
|
||||
animation: whatsNewSlideIn 0.22s ease-out both;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.whats-new-card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lpBlink {
|
||||
0%,
|
||||
49% {
|
||||
|
||||
@@ -8,7 +8,15 @@ import { useGlobalAdminUi } from '@/hooks/useGlobalAdminUi';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { api } from '@/lib/api';
|
||||
import { isUrlDetail, visibleHealthChecks, visibleHealthDetails } from '@/lib/healthCheckView';
|
||||
import {
|
||||
isUrlDetail,
|
||||
visibleHealthChecks,
|
||||
visibleHealthDetails,
|
||||
resolveMcpCardState,
|
||||
MCP_WARMING_POLL_MS,
|
||||
MCP_PROBE_TIMEOUT_MS,
|
||||
} from '@/lib/healthCheckView';
|
||||
import { useLatest } from '@/hooks/useLatest';
|
||||
import type { DetailedHealthCheckResponse, HealthCheck, McpHealthCheckResponse } from '@/utils/wire-types.generated';
|
||||
import { DocsLink } from "@/components/DocsLink";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
@@ -81,6 +89,9 @@ export default function HealthCheckPage() {
|
||||
// dashboard on it. The card renders a "checking" state immediately and updates in place.
|
||||
const [mcpCheck, setMcpCheck] = useState<HealthCheck | null>(null);
|
||||
const [mcpLoading, setMcpLoading] = useState(false);
|
||||
// Counts only the automatic warming re-probes; reset solely by the Re-check button, so
|
||||
// a reset inside performMcpCheck can never make the poll loop immortal.
|
||||
const [mcpAttempts, setMcpAttempts] = useState(0);
|
||||
// Portal build stamp: /version.json is a static file from this same origin (generated at
|
||||
// build time, Cache-Control: no-store) — plain fetch, no auth. Card stays hidden when the
|
||||
// file is absent (e.g. a dev server without the prebuild step).
|
||||
@@ -113,14 +124,16 @@ export default function HealthCheckPage() {
|
||||
|
||||
// Separate, non-blocking probe for the MCP server. Runs concurrently with — and
|
||||
// independently of — performHealthCheck so a cold-starting MCP container never holds
|
||||
// up the other cards. Resolves to healthy / warning / unhealthy; the card shows a
|
||||
// "checking" state until then.
|
||||
// up the other cards. The server answers "warming" for a scaled-to-zero container
|
||||
// instead of holding the request; the effect below then re-probes until it is up.
|
||||
const performMcpCheck = useCallback(async () => {
|
||||
setMcpLoading(true);
|
||||
try {
|
||||
let data: McpHealthCheckResponse;
|
||||
try {
|
||||
data = await fetchJson<McpHealthCheckResponse>(api.health.mcp(), getAccessToken);
|
||||
data = await fetchJson<McpHealthCheckResponse>(api.health.mcp(), getAccessToken, {
|
||||
signal: AbortSignal.timeout(MCP_PROBE_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError)) throw err;
|
||||
setMcpCheck({
|
||||
@@ -135,18 +148,42 @@ export default function HealthCheckPage() {
|
||||
}
|
||||
if (data?.check) setMcpCheck(data.check);
|
||||
} catch (error) {
|
||||
// Network/token errors: surface on the card itself, never as a blocking page error.
|
||||
// Our own client-side budget expiring means the same thing the server's does: the
|
||||
// container is still starting. Keep it on the warming track so the re-poll continues.
|
||||
const clientTimedOut = error instanceof DOMException && error.name === 'TimeoutError';
|
||||
setMcpCheck({
|
||||
name: 'MCP Server',
|
||||
description: 'AI query interface availability',
|
||||
status: 'warning',
|
||||
message: error instanceof Error ? error.message : 'MCP status check could not complete',
|
||||
status: clientTimedOut ? 'warming' : 'warning',
|
||||
message: clientTimedOut
|
||||
// Never rendered — resolveMcpCardState writes the warming line itself — but it
|
||||
// keeps this object honest for anything that reads the raw check.
|
||||
? 'MCP server is starting (probe timed out)'
|
||||
: error instanceof Error ? error.message : 'MCP status check could not complete',
|
||||
});
|
||||
} finally {
|
||||
setMcpLoading(false);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
const mcpCardState = resolveMcpCardState({ check: mcpCheck, loading: mcpLoading, attempts: mcpAttempts });
|
||||
const { display: mcpDisplay, ratedStatus: mcpRated, shouldPoll: mcpShouldPoll } = mcpCardState;
|
||||
|
||||
// Re-probe while the MCP container is warming, so the card turns green on its own.
|
||||
// useLatest keeps the interval from being torn down and rebuilt whenever the callback
|
||||
// identity changes — that would reset the cadence and desync the attempt counter.
|
||||
const mcpProbeRef = useLatest(performMcpCheck);
|
||||
const mcpLoadingRef = useLatest(mcpLoading);
|
||||
useEffect(() => {
|
||||
if (!mcpShouldPoll) return;
|
||||
const id = window.setInterval(() => {
|
||||
if (mcpLoadingRef.current) return; // never stack probes
|
||||
setMcpAttempts(a => a + 1);
|
||||
void mcpProbeRef.current();
|
||||
}, MCP_WARMING_POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [mcpShouldPoll, mcpProbeRef, mcpLoadingRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBuildDetails) return;
|
||||
let cancelled = false;
|
||||
@@ -203,16 +240,17 @@ export default function HealthCheckPage() {
|
||||
case 'healthy': return { bg: 'bg-green-50 dark:bg-green-900/20', border: 'border-green-200 dark:border-green-800', text: 'text-green-700 dark:text-green-400', accent: 'border-green-500', badge: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400' };
|
||||
case 'unhealthy': return { bg: 'bg-red-50 dark:bg-red-900/20', border: 'border-red-200 dark:border-red-800', text: 'text-red-700 dark:text-red-400', accent: 'border-red-500', badge: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-400' };
|
||||
case 'warning': return { bg: 'bg-yellow-50 dark:bg-yellow-900/20', border: 'border-yellow-200 dark:border-yellow-800', text: 'text-yellow-700 dark:text-yellow-400', accent: 'border-yellow-500', badge: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-400' };
|
||||
case 'checking': return { bg: 'bg-blue-50 dark:bg-blue-900/20', border: 'border-blue-200 dark:border-blue-800', text: 'text-blue-700 dark:text-blue-400', accent: 'border-blue-500', badge: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-400' };
|
||||
// "warming" shares the blue in-progress look: a scale-from-zero cold start is
|
||||
// expected behaviour, and yellow would read as a fault.
|
||||
case 'checking':
|
||||
case 'warming': return { bg: 'bg-blue-50 dark:bg-blue-900/20', border: 'border-blue-200 dark:border-blue-800', text: 'text-blue-700 dark:text-blue-400', accent: 'border-blue-500', badge: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-400' };
|
||||
default: return { bg: 'bg-gray-50 dark:bg-gray-800', border: 'border-gray-200 dark:border-gray-700', text: 'text-gray-700 dark:text-gray-300', accent: 'border-gray-500', badge: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' };
|
||||
}
|
||||
};
|
||||
|
||||
const connStatus = getConnectionStatus();
|
||||
// The MCP card only contributes to the banner/counts once it has resolved to a rated
|
||||
// status. While it is still probing (null/loading) or not viewable (unknown), it stays
|
||||
// neutral so a slow cold start never flips the overall banner to "warning".
|
||||
const mcpRated = mcpCheck && mcpCheck.status !== 'unknown' ? mcpCheck.status : null;
|
||||
// mcpRated comes from resolveMcpCardState: null while probing, warming or not viewable,
|
||||
// so a cold start never flips the overall banner. See lib/healthCheckView.ts.
|
||||
const totalChecks = healthResult ? healthResult.checks.length + 1 + (mcpRated ? 1 : 0) : 0;
|
||||
const healthyChecks = healthResult
|
||||
? healthResult.checks.filter(c => c.status === 'healthy').length
|
||||
@@ -236,7 +274,7 @@ export default function HealthCheckPage() {
|
||||
<h1 className="text-2xl font-normal text-gray-900 dark:text-white">System Health</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => { performHealthCheck(); performMcpCheck(); }}
|
||||
onClick={() => { setMcpAttempts(0); performHealthCheck(); performMcpCheck(); }}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
@@ -446,12 +484,13 @@ export default function HealthCheckPage() {
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Individual Check Cards */}
|
||||
{healthResult && (
|
||||
<div>
|
||||
{/* Individual Check Cards. The section is NOT gated on healthResult: the MCP card
|
||||
runs on its own track (and re-polls itself through a cold start), so a failing
|
||||
detailed check must not hide it — that would leave the poll running invisibly. */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Backend Services</h2>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{visibleHealthChecks(healthResult.checks, operatorView).map((check, index) => {
|
||||
{(healthResult ? visibleHealthChecks(healthResult.checks, operatorView) : []).map((check, index) => {
|
||||
const colors = getStatusColor(check.status);
|
||||
return (
|
||||
<div key={index} className={`bg-white dark:bg-gray-800 rounded-lg shadow border-l-4 ${colors.accent}`}>
|
||||
@@ -493,13 +532,11 @@ export default function HealthCheckPage() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* MCP Server — fetched on its own track; renders a "checking" state while the
|
||||
probe (possibly waking the scaled-to-zero container) is in flight, then
|
||||
updates in place without blocking any of the cards above. */}
|
||||
{/* MCP Server — fetched on its own track; a scaled-to-zero container reports
|
||||
"warming" and the card re-probes itself until it is up, without blocking
|
||||
any of the cards above. State machine: lib/healthCheckView.ts. */}
|
||||
{(() => {
|
||||
const display = mcpLoading
|
||||
? { name: 'MCP Server', description: 'AI query interface availability', status: 'checking', message: 'Probing MCP server — waking it from idle if needed…', details: undefined as Record<string, unknown> | undefined }
|
||||
: (mcpCheck ?? { name: 'MCP Server', description: 'AI query interface availability', status: 'unknown', message: 'Not checked yet', details: undefined as Record<string, unknown> | undefined });
|
||||
const display = mcpDisplay;
|
||||
const colors = getStatusColor(display.status);
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow border-l-4 ${colors.accent}`}>
|
||||
@@ -507,7 +544,7 @@ export default function HealthCheckPage() {
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full ${colors.bg} flex items-center justify-center`}>
|
||||
{display.status === 'checking' ? (
|
||||
{display.status === 'checking' || display.status === 'warming' ? (
|
||||
<svg className="w-5 h-5 text-blue-600 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
||||
@@ -531,7 +568,7 @@ export default function HealthCheckPage() {
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors.badge}`}>
|
||||
{display.status === 'checking' ? 'checking…' : display.status}
|
||||
{display.status === 'checking' ? 'checking…' : display.status === 'warming' ? 'warming…' : display.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -546,9 +583,9 @@ export default function HealthCheckPage() {
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { WhatsNewTile } from "../../components/landing/WhatsNewTile";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
@@ -6,6 +7,9 @@ const GITHUB_ISSUES = "https://github.com/okieselbach/AutopilotMonitor/issues";
|
||||
const LINKEDIN_PROFILE = "https://www.linkedin.com/in/oliver-kieselbach/";
|
||||
const GITHUB_ADVISORY = "https://github.com/okieselbach/AutopilotMonitor/security/advisories/new";
|
||||
|
||||
const TILE_CLASS =
|
||||
"rounded-xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-5 py-4 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors";
|
||||
|
||||
const SELF_SERVICE_LINKS = [
|
||||
{
|
||||
label: "Documentation",
|
||||
@@ -22,11 +26,6 @@ const SELF_SERVICE_LINKS = [
|
||||
description: "Known issues and current service status.",
|
||||
href: `${DOCS_URL}/troubleshooting/service-announcements`,
|
||||
},
|
||||
{
|
||||
label: "Platform Changelog",
|
||||
description: "What changed recently in the portal and backend.",
|
||||
href: `${DOCS_URL}/changelog/platform-changelog`,
|
||||
},
|
||||
];
|
||||
|
||||
export default function HelpPage() {
|
||||
@@ -142,12 +141,13 @@ export default function HelpPage() {
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-[var(--lp-line-soft)] bg-[var(--lp-surface)] px-5 py-4 hover:border-[var(--lp-accent-line)] hover:bg-[var(--lp-accent-soft)] transition-colors"
|
||||
className={TILE_CLASS}
|
||||
>
|
||||
<span className="block text-sm font-semibold text-gray-800 mb-1">{link.label} →</span>
|
||||
<span className="block text-sm text-gray-600 leading-relaxed">{link.description}</span>
|
||||
</a>
|
||||
))}
|
||||
<WhatsNewTile className={TILE_CLASS} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ThemeProvider } from "../contexts/ThemeContext";
|
||||
import Navbar from "../components/Navbar";
|
||||
import ScrollToTopButton from "../components/ScrollToTopButton";
|
||||
import FeedbackBubble from "../components/FeedbackBubble";
|
||||
import { WhatsNewPanelHost } from "../components/WhatsNewPanelHost";
|
||||
import { SidebarProvider } from "../contexts/SidebarContext";
|
||||
import { GlobalSidebar } from "../components/GlobalSidebar";
|
||||
import AppInsightsInit from "../components/AppInsightsInit";
|
||||
@@ -198,6 +199,7 @@ export default function RootLayout({
|
||||
</GlobalSidebar>
|
||||
<ScrollToTopButton />
|
||||
<FeedbackBubble />
|
||||
<WhatsNewPanelHost />
|
||||
</SidebarProvider>
|
||||
</TenantProvider>
|
||||
</TenantNotificationProvider>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { makeEvent, makeSession } from "@/test/factories";
|
||||
import {
|
||||
buildProgressSteps,
|
||||
computeOverallProgress,
|
||||
isParkedAfterTechnicianPart,
|
||||
lastDeclaredPhase,
|
||||
resolveActiveStepIndex,
|
||||
resolvePresentation,
|
||||
@@ -121,6 +122,35 @@ describe("resolveActiveStepIndex", () => {
|
||||
expect(resolveActiveStepIndex({ steps, session: failed, events: [], hasAppActivity: true })).toBe(3);
|
||||
expect(resolveActiveStepIndex({ steps, session: failed, events: [], hasAppActivity: false })).toBe(0);
|
||||
});
|
||||
|
||||
it("pre-provisioned device parked after the technician part sits on Account setup with every device step done", () => {
|
||||
const steps = buildProgressSteps({ ...v1, isPreProvisioned: true }, false);
|
||||
const parked = { ...v1, isPreProvisioned: true, currentPhase: 3 };
|
||||
const sealed = [ev(1, "esp_phase_changed", 3), ev(2, "whiteglove_complete"), ev(3, "agent_shutting_down", 7)];
|
||||
// The backend caps the stored phase at Apps (Device); without the seal that step would stay "current" forever.
|
||||
expect(resolveActiveStepIndex({ steps, session: parked, events: sealed, hasAppActivity: true })).toBe(4);
|
||||
expect(computeOverallProgress("InProgress", 4, steps.length)).toBe(57);
|
||||
// Older sessions carry a stale Complete phase — the seal must not push the device past the sign-in either.
|
||||
expect(resolveActiveStepIndex({ steps, session: { ...parked, currentPhase: 7 }, events: sealed, hasAppActivity: true })).toBe(4);
|
||||
// Still in Part 1 (no seal yet): phase-driven as usual.
|
||||
expect(resolveActiveStepIndex({ steps, session: parked, events: [ev(1, "esp_phase_changed", 3)], hasAppActivity: true })).toBe(3);
|
||||
// Resumed by the user (either signal): phase-driven again.
|
||||
expect(resolveActiveStepIndex({ steps, session: { ...parked, resumedAt: "2026-08-28T11:00:00Z", currentPhase: 5 }, events: sealed, hasAppActivity: true })).toBe(5);
|
||||
expect(resolveActiveStepIndex({ steps, session: { ...parked, currentPhase: 4 }, events: [...sealed, ev(4, "whiteglove_resumed")], hasAppActivity: true })).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isParkedAfterTechnicianPart", () => {
|
||||
it("needs the pre-provisioned flag, the seal and no resume in either form", () => {
|
||||
const sealed = [ev(1, "whiteglove_complete")];
|
||||
expect(isParkedAfterTechnicianPart({ isPreProvisioned: true, resumedAt: undefined }, sealed)).toBe(true);
|
||||
expect(isParkedAfterTechnicianPart({ isPreProvisioned: false, resumedAt: undefined }, sealed)).toBe(false);
|
||||
expect(isParkedAfterTechnicianPart({ isPreProvisioned: true, resumedAt: undefined }, [])).toBe(false);
|
||||
expect(isParkedAfterTechnicianPart({ isPreProvisioned: true, resumedAt: "2026-08-28T11:00:00Z" }, sealed)).toBe(false);
|
||||
expect(isParkedAfterTechnicianPart({ isPreProvisioned: true, resumedAt: undefined }, [...sealed, ev(2, "whiteglove_resumed")])).toBe(false);
|
||||
// Race: Windows may write the seal after the Part-2 reboot — the resume still wins.
|
||||
expect(isParkedAfterTechnicianPart({ isPreProvisioned: true, resumedAt: undefined }, [ev(1, "whiteglove_resumed"), ev(2, "whiteglove_complete")])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastDeclaredPhase", () => {
|
||||
|
||||
@@ -109,9 +109,30 @@ export function lastDeclaredPhase(events: EnrollmentEvent[]): number {
|
||||
return max;
|
||||
}
|
||||
|
||||
const FIRST_USER_PHASE_ID = 4;
|
||||
|
||||
/**
|
||||
* A pre-provisioned device whose technician part (whiteglove_complete) finished and that no
|
||||
* user has resumed yet. The backend caps such a session at the last device phase, so a
|
||||
* phase-driven reading would keep "Installing apps (device)" running forever; both the
|
||||
* headline and the step list must read the seal instead.
|
||||
*/
|
||||
export function isParkedAfterTechnicianPart(
|
||||
session: Pick<ProgressSession, "isPreProvisioned" | "resumedAt">,
|
||||
events: EnrollmentEvent[],
|
||||
): boolean {
|
||||
if (!session.isPreProvisioned || session.resumedAt) return false;
|
||||
let sealed = false;
|
||||
for (const e of events) {
|
||||
if (e.eventType === "whiteglove_resumed") return false;
|
||||
if (e.eventType === "whiteglove_complete") sealed = true;
|
||||
}
|
||||
return sealed;
|
||||
}
|
||||
|
||||
export interface ResolveActiveStepParams {
|
||||
steps: ProgressStep[];
|
||||
session: Pick<ProgressSession, "status" | "currentPhase" | "enrollmentType">;
|
||||
session: Pick<ProgressSession, "status" | "currentPhase" | "enrollmentType" | "isPreProvisioned" | "resumedAt">;
|
||||
events: EnrollmentEvent[];
|
||||
/** Any app download/install/summary evidence in the event stream. */
|
||||
hasAppActivity: boolean;
|
||||
@@ -125,11 +146,20 @@ export interface ResolveActiveStepParams {
|
||||
*
|
||||
* Device Preparation never declares the app phase (the agent's sub-phase declaration is
|
||||
* ESP-driven), so on v2 the app step is promoted from app-event evidence instead.
|
||||
*
|
||||
* A pre-provisioned device parked after its technician part sits on the first user step:
|
||||
* every device step is done, and the stored phase (capped at the device apps, or a stale
|
||||
* Complete on older sessions) must neither hold it back nor push it past the sign-in.
|
||||
*/
|
||||
export function resolveActiveStepIndex({ steps, session, events, hasAppActivity }: ResolveActiveStepParams): number {
|
||||
if (steps.length === 0) return 0;
|
||||
if (session.status === "Succeeded") return steps.length;
|
||||
|
||||
if (isParkedAfterTechnicianPart(session, events)) {
|
||||
const userStepIndex = steps.findIndex((s) => s.id >= FIRST_USER_PHASE_ID);
|
||||
if (userStepIndex >= 0) return userStepIndex;
|
||||
}
|
||||
|
||||
let phase = session.currentPhase;
|
||||
if (phase === FAILED_PHASE_ID || session.status === "Failed") {
|
||||
phase = lastDeclaredPhase(events);
|
||||
@@ -189,11 +219,7 @@ export function resolvePresentation(session: ProgressSession, events: Enrollment
|
||||
|
||||
// Non-terminal. A pre-provisioned device whose technician part finished and that has not
|
||||
// been resumed by its user is waiting, not working.
|
||||
if (
|
||||
session.isPreProvisioned &&
|
||||
!session.resumedAt &&
|
||||
events.some((e) => e.eventType === "whiteglove_complete")
|
||||
) {
|
||||
if (isParkedAfterTechnicianPart(session, events)) {
|
||||
return awaitingUser(session);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user