mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
feat(autopilot): show "not registered" devices (14-day window) under enrollment validation
Adds a tenant-scoped, informational overview of devices that failed enrollment with HTTP 403 because they were not in the tenant's Autopilot / Corporate Identifier registry. Mirrors the Hardware Whitelist rejection insights but aggregates per device (serial number), matching how Autopilot registers individual devices rather than models. Read-only, no webhook. Backend - GetDeviceNotRegisteredFunction: route audit/device-not-registered, MemberRead, filters distress reports on ErrorType == "DeviceNotRegistered", aggregates by serial (case-insensitive; empty serial -> single bucket); 14-day horizon is the existing distress-report retention. - Register route in EndpointAccessPolicyCatalog (fail-closed). - 8 unit tests for BuildAggregatedResult. Frontend - api.distress.deviceNotRegistered(). - NotRegisteredDevicesInsights component (serial/mfr/model/attempts/first/last seen, green empty-state, unverified-data notice). - Wired below AutopilotValidationSection. DeviceNotRegistered (403) covers both Autopilot and Corporate Identifier validation; the agent cannot distinguish which failed, so copy is neutral. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
using AutopilotMonitor.Functions.Functions.Reports;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the device-not-registered aggregation logic extracted from
|
||||
/// GetDeviceNotRegisteredFunction. All tests target the pure static BuildAggregatedResult
|
||||
/// method — no HTTP or DI needed. Aggregation is by serial number (Autopilot registers
|
||||
/// individual devices, not models).
|
||||
/// </summary>
|
||||
public class GetDeviceNotRegisteredFunctionTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// =========================================================================
|
||||
// Empty / no matching reports
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_EmptyList_ReturnsEmpty()
|
||||
{
|
||||
var (aggregated, totalRawReports) = GetDeviceNotRegisteredFunction.BuildAggregatedResult([]);
|
||||
|
||||
Assert.Empty(aggregated);
|
||||
Assert.Equal(0, totalRawReports);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_FiltersDeviceNotRegisteredOnly()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0),
|
||||
MakeEntry("HardwareNotAllowed", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("AuthCertificateRejected", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, totalRawReports) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
Assert.Equal(1, totalRawReports);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Aggregation by serial number
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_AggregatesBySerialCaseInsensitive()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "GM18NHV3", T0),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "gm18nhv3", T0.AddMinutes(1)),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", " GM18NHV3 ", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, totalRawReports) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
Assert.Equal(3, totalRawReports);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_DistinctSerials_ProduceDistinctRows()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkCentre M70q", "SN3", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, totalRawReports) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Equal(3, aggregated.Count);
|
||||
Assert.Equal(3, totalRawReports);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_UsesMostRecentManufacturerModel()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "OldMfr", "OldModel", "SN1", T0),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0.AddMinutes(5)),
|
||||
};
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal("Lenovo", (string)item.manufacturer);
|
||||
Assert.Equal("ThinkPad T14", (string)item.model);
|
||||
Assert.Equal("SN1", (string)item.serialNumber);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Timestamps
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_FirstSeenLastSeen_Correct()
|
||||
{
|
||||
var t1 = T0;
|
||||
var t2 = T0.AddHours(1);
|
||||
var t3 = T0.AddHours(2);
|
||||
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", t2),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", t1),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", t3),
|
||||
};
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(t1, (DateTime)item.firstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.lastSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_OrderedByLastSeenDescending()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad X13", "SN2", T0.AddHours(2)),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkCentre", "SN3", T0.AddHours(1)),
|
||||
};
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Equal(3, aggregated.Count);
|
||||
var first = ToDynamic(aggregated[0]);
|
||||
var second = ToDynamic(aggregated[1]);
|
||||
var third = ToDynamic(aggregated[2]);
|
||||
|
||||
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
|
||||
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Null / empty serial number
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_NullOrEmptySerials_GroupedIntoSingleBucket()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad", null, T0),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad", "", T0.AddMinutes(1)),
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad", " ", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static DistressReportEntry MakeEntry(
|
||||
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) =>
|
||||
new()
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
ErrorType = errorType,
|
||||
Manufacturer = manufacturer,
|
||||
Model = model,
|
||||
SerialNumber = serial,
|
||||
IngestedAt = ingestedAt,
|
||||
AgentTimestamp = ingestedAt,
|
||||
};
|
||||
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant-scoped endpoint: returns devices that failed enrollment because they were not
|
||||
/// registered in the tenant's Autopilot / Corporate Identifier registry (backend 403),
|
||||
/// aggregated by serial number. Data comes from distress reports with
|
||||
/// ErrorType == "DeviceNotRegistered".
|
||||
///
|
||||
/// Informational only — surfaces a 14-day window of unregistered devices so admins can
|
||||
/// sanity-check their enrollment scoping/assignment. The 14-day horizon is enforced by the
|
||||
/// distress-report retention cleanup, not by this function.
|
||||
/// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
|
||||
/// </summary>
|
||||
public class GetDeviceNotRegisteredFunction
|
||||
{
|
||||
private readonly ILogger<GetDeviceNotRegisteredFunction> _logger;
|
||||
private readonly IDistressReportRepository _repository;
|
||||
|
||||
public GetDeviceNotRegisteredFunction(
|
||||
ILogger<GetDeviceNotRegisteredFunction> logger,
|
||||
IDistressReportRepository repository)
|
||||
{
|
||||
_logger = logger;
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
[Function("GetDeviceNotRegistered")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "audit/device-not-registered")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200);
|
||||
var (aggregated, totalRawReports) = BuildAggregatedResult(reports);
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
success = true,
|
||||
aggregated,
|
||||
totalRawReports,
|
||||
dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, and model values are self-reported by devices."
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "Get devices not registered");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters distress reports to DeviceNotRegistered and aggregates by serial number.
|
||||
/// Reports with no serial number are grouped into a single "unknown serial" bucket.
|
||||
/// Extracted as public static for testability.
|
||||
/// </summary>
|
||||
public static (List<object> aggregated, int totalRawReports) BuildAggregatedResult(
|
||||
List<DistressReportEntry> reports)
|
||||
{
|
||||
var notRegistered = reports
|
||||
.Where(r => string.Equals(r.ErrorType, "DeviceNotRegistered", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var aggregated = notRegistered
|
||||
.GroupBy(r => (r.SerialNumber ?? "").Trim().ToLowerInvariant())
|
||||
.Select(g =>
|
||||
{
|
||||
var mostRecent = g.OrderByDescending(r => r.IngestedAt).First();
|
||||
|
||||
return new
|
||||
{
|
||||
serialNumber = mostRecent.SerialNumber ?? "",
|
||||
manufacturer = mostRecent.Manufacturer ?? "",
|
||||
model = mostRecent.Model ?? "",
|
||||
attemptCount = g.Count(),
|
||||
firstSeen = g.Min(r => r.IngestedAt),
|
||||
lastSeen = g.Max(r => r.IngestedAt)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(a => a.lastSeen)
|
||||
.ToList<object>();
|
||||
|
||||
return (aggregated, notRegistered.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "metrics/ime-versions", EndpointPolicy.MemberRead),
|
||||
new("GET", "audit/logs", EndpointPolicy.MemberRead),
|
||||
new("GET", "audit/hardware-rejected", EndpointPolicy.MemberRead),
|
||||
new("GET", "audit/device-not-registered", EndpointPolicy.MemberRead),
|
||||
new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "rules/gather", EndpointPolicy.MemberRead),
|
||||
new("GET", "rules/analyze", EndpointPolicy.MemberRead),
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch } from "@/lib/authenticatedFetch";
|
||||
|
||||
interface AggregatedNotRegistered {
|
||||
serialNumber: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
attemptCount: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
interface DeviceNotRegisteredResponse {
|
||||
success: boolean;
|
||||
aggregated: AggregatedNotRegistered[];
|
||||
totalRawReports: number;
|
||||
dataQualityNotice: string;
|
||||
}
|
||||
|
||||
interface NotRegisteredDevicesInsightsProps {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotRegisteredDevicesInsights({
|
||||
getAccessToken,
|
||||
}: NotRegisteredDevicesInsightsProps) {
|
||||
const [data, setData] = useState<DeviceNotRegisteredResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await authenticatedFetch(api.distress.deviceNotRegistered(), getAccessToken);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: DeviceNotRegisteredResponse = await res.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow mt-6">
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-5 bg-gray-200 rounded w-48" />
|
||||
<div className="h-4 bg-gray-100 rounded w-full" />
|
||||
<div className="h-4 bg-gray-100 rounded w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow mt-6">
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-red-600">Failed to load unregistered devices: {error}</p>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="mt-2 text-sm text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDevices = data && data.aggregated.length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow mt-6">
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b border-gray-200 bg-gradient-to-r ${hasDevices ? "from-amber-50 to-orange-50" : "from-green-50 to-emerald-50"}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasDevices ? (
|
||||
<svg className="w-6 h-6 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
<div>
|
||||
<h3 className={`text-lg font-semibold ${hasDevices ? "text-gray-900" : "text-green-800"}`}>
|
||||
{hasDevices ? "Devices Not Registered" : "No devices reported as not registered in the last 14 days"}
|
||||
{hasDevices && <span className="ml-2 text-xs font-normal text-gray-500">Last 14 days</span>}
|
||||
</h3>
|
||||
{hasDevices && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Devices rejected with HTTP 403 because they were not found in this tenant's Autopilot or Corporate Identifier registry
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasDevices && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
|
||||
Unverified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty-state hint */}
|
||||
{!hasDevices && (
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
When a device attempts to enroll but is not part of your Autopilot or Corporate Identifier
|
||||
scope, it appears here. An empty list means every device that reached the backend was in scope.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data quality notice + table (only when devices exist) */}
|
||||
{hasDevices && (
|
||||
<>
|
||||
<div className="px-6 pt-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-xs text-amber-800">
|
||||
This data comes from pre-authentication distress signals. Serial number, manufacturer, and
|
||||
model values are self-reported and unverified. Use it to sanity-check whether your enrollment
|
||||
scoping/assignment covers these devices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 uppercase tracking-wider border-b border-gray-200">
|
||||
<th className="pb-2 pr-4">Serial Number</th>
|
||||
<th className="pb-2 pr-4">Manufacturer</th>
|
||||
<th className="pb-2 pr-4">Model</th>
|
||||
<th className="pb-2 pr-4 text-right">Attempts</th>
|
||||
<th className="pb-2 pr-4">First Seen</th>
|
||||
<th className="pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data!.aggregated.map((row, idx) => (
|
||||
<tr key={`${row.serialNumber}|${idx}`}>
|
||||
<td className="py-3 pr-4 font-mono text-gray-900">{row.serialNumber || "–"}</td>
|
||||
<td className="py-3 pr-4 text-gray-700">{row.manufacturer || "–"}</td>
|
||||
<td className="py-3 pr-4 text-gray-700">{row.model || "–"}</td>
|
||||
<td className="py-3 pr-4 text-right font-mono text-gray-900">{row.attemptCount}</td>
|
||||
<td className="py-3 pr-4 text-gray-500 whitespace-nowrap">{formatDate(row.firstSeen)}</td>
|
||||
<td className="py-3 text-gray-500 whitespace-nowrap">{formatDate(row.lastSeen)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
{data!.totalRawReports} total rejection{data!.totalRawReports !== 1 ? "s" : ""} across {data!.aggregated.length} device{data!.aggregated.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-1
@@ -4,6 +4,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useTenantConfig } from "../../TenantConfigContext";
|
||||
import { TenantNotifications } from "../../TenantNotifications";
|
||||
import AutopilotValidationSection from "../../components/AutopilotValidationSection";
|
||||
import NotRegisteredDevicesInsights from "../../components/NotRegisteredDevicesInsights";
|
||||
|
||||
export function SectionAutopilotValidation() {
|
||||
const {
|
||||
@@ -15,7 +16,7 @@ export function SectionAutopilotValidation() {
|
||||
beginDeviceValidationConsentFlow,
|
||||
} = useTenantConfig();
|
||||
|
||||
const { user } = useAuth();
|
||||
const { user, getAccessToken } = useAuth();
|
||||
// DevPrep "Device association" is in Microsoft Private Preview — surface only to Global Admins
|
||||
// until it ships GA. Backend always rejects writes from non-GA callers regardless of UI.
|
||||
// TODO(devprep-followup): add vitest DOM-render test asserting toggle is hidden for
|
||||
@@ -39,6 +40,7 @@ export function SectionAutopilotValidation() {
|
||||
saving={savingSection === "autopilotValidation"}
|
||||
onBeginConsent={beginDeviceValidationConsentFlow}
|
||||
/>
|
||||
<NotRegisteredDevicesInsights getAccessToken={getAccessToken} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ export const api = {
|
||||
// ── Hardware Rejection Insights (tenant-scoped, from distress data) ──────
|
||||
distress: {
|
||||
hardwareRejected: () => `${API_BASE_URL}/api/audit/hardware-rejected`,
|
||||
deviceNotRegistered: () => `${API_BASE_URL}/api/audit/device-not-registered`,
|
||||
},
|
||||
|
||||
// ── Ops Events ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user