consistent switcher everywhere

This commit is contained in:
Oliver Kieselbach
2026-04-15 01:18:14 +02:00
parent 47a73c08d5
commit d178fa4ef3
6 changed files with 206 additions and 47 deletions
@@ -37,9 +37,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
var tenantIdFilter = query["tenantId"];
var cutoff = DateTime.UtcNow.AddDays(-days);
var allSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync();
var allSummaries = !string.IsNullOrWhiteSpace(tenantIdFilter)
? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter)
: await _metricsRepo.GetAllAppInstallSummariesAsync();
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
var appGroups = summaries.GroupBy(s => s.AppName).Select(g =>
@@ -41,10 +41,15 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
days = parsedDays;
var groupBy = query["groupBy"] ?? "city";
var tenantIdFilter = query["tenantId"];
var cutoff = DateTime.UtcNow.AddDays(-days);
var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1));
var allSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync();
var sessions = !string.IsNullOrWhiteSpace(tenantIdFilter)
? await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantIdFilter)
: await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1));
var allSummaries = !string.IsNullOrWhiteSpace(tenantIdFilter)
? await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantIdFilter)
: await _metricsRepo.GetAllAppInstallSummariesAsync();
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(sessions, summaries, groupBy);
@@ -33,6 +33,11 @@ interface AppMetricsResponse {
topFailingApps: AppMetric[];
}
interface TenantInfo {
tenantId: string;
domainName: string;
}
export default function FleetHealthPage() {
const router = useRouter();
@@ -41,44 +46,90 @@ export default function FleetHealthPage() {
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("7d");
const hasInitialFetch = useRef(false);
const hasJoinedGroup = useRef(false);
const isTimeRangeMount = useRef(true);
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const { getAccessToken, user } = useAuth();
const { addNotification } = useNotifications();
const { globalAdminMode } = useAdminMode();
useEffect(() => {
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
Promise.all([fetchSessions(), fetchAppMetrics(timeRange)]);
}, [tenantId, globalAdminMode]);
// Global admin: tenant selector state.
// Default for GAs is their own tenant (set once tenantId loads), not aggregated —
// gives the standard "view your tenant first, opt-in to aggregated/override".
const [tenants, setTenants] = useState<TenantInfo[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
const [scopeInitialized, setScopeInitialized] = useState(false);
const isGlobalAdmin = Boolean(globalAdminMode && user?.isGlobalAdmin);
const isAggregatedGlobalView = Boolean(isGlobalAdmin && !selectedTenantId);
const isGlobalOverride = Boolean(
isGlobalAdmin && selectedTenantId && selectedTenantId !== tenantId
);
const effectiveTenantId = isGlobalAdmin ? selectedTenantId : tenantId;
useEffect(() => {
if (!isGlobalAdmin) return;
const loadTenants = async () => {
try {
const response = await authenticatedFetch(api.config.all(), getAccessToken);
if (response.ok) {
const data = await response.json();
const mapped: TenantInfo[] = data.map((t: { tenantId: string; domainName: string }) => ({
tenantId: t.tenantId,
domainName: t.domainName || "",
}));
mapped.sort((a, b) =>
(a.domainName || a.tenantId).localeCompare(b.domainName || b.tenantId)
);
setTenants(mapped);
}
} catch (err) {
console.error("Error fetching tenant list:", err);
}
};
loadTenants();
}, [isGlobalAdmin, getAccessToken]);
const selectedTenantName = useMemo(
() => tenants.find((t) => t.tenantId === selectedTenantId)?.domainName,
[tenants, selectedTenantId]
);
useEffect(() => {
if (scopeInitialized) return;
if (!tenantId) return;
if (isGlobalAdmin) {
setSelectedTenantId(tenantId);
}
setScopeInitialized(true);
}, [tenantId, isGlobalAdmin, scopeInitialized]);
useEffect(() => {
if (!scopeInitialized) return;
if (isTimeRangeMount.current) {
isTimeRangeMount.current = false;
return;
}
Promise.all([fetchSessions(timeRange), fetchAppMetrics(timeRange)]);
}, [timeRange]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeInitialized, timeRange, selectedTenantId]);
useEffect(() => {
if (isConnected && !hasJoinedGroup.current) {
joinGroup(`tenant-${tenantId}`);
hasJoinedGroup.current = true;
}
if (!isConnected) return;
if (!effectiveTenantId) return; // no group in aggregated mode
if (hasJoinedGroup.current) return;
const group = `tenant-${effectiveTenantId}`;
joinGroup(group);
hasJoinedGroup.current = true;
return () => {
if (hasJoinedGroup.current) {
leaveGroup(`tenant-${tenantId}`);
leaveGroup(group);
hasJoinedGroup.current = false;
}
};
}, [isConnected, tenantId]);
}, [isConnected, effectiveTenantId]);
useEffect(() => {
const handleNewSession = (data: { session: Session }) => {
@@ -123,8 +174,8 @@ export default function FleetHealthPage() {
const fetchSessions = async (range: "7d" | "30d" | "90d" = timeRange) => {
try {
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const endpoint = globalAdminMode
? api.globalSessions.list(undefined, days)
const endpoint = isGlobalAdmin
? api.globalSessions.list(selectedTenantId || undefined, days)
: api.sessions.list(tenantId, days);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
@@ -148,8 +199,8 @@ export default function FleetHealthPage() {
const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => {
try {
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const endpoint = globalAdminMode
? api.metrics.globalApp(days)
const endpoint = isGlobalAdmin
? api.metrics.globalApp(days, selectedTenantId || undefined)
: api.metrics.app(tenantId, days);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
@@ -346,13 +397,20 @@ export default function FleetHealthPage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
{globalAdminMode && (
{isGlobalAdmin && (
<div className="bg-purple-700 text-white text-sm px-4 py-2 flex items-center justify-center space-x-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">Global Admin View</span>
<span className="text-purple-300">&mdash; aggregating data across all tenants</span>
<span className="text-purple-300">
&mdash;{" "}
{isAggregatedGlobalView
? "aggregating across all tenants"
: isGlobalOverride
? `viewing tenant ${selectedTenantName ?? selectedTenantId}`
: "access to all tenants"}
</span>
</div>
)}
<header className="bg-white shadow">
@@ -378,7 +436,26 @@ export default function FleetHealthPage() {
</h1>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-3">
{isGlobalAdmin && tenants.length > 0 && (
<>
<label className="text-sm text-gray-500 hidden sm:inline">Tenant:</label>
<select
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
className="text-sm border border-gray-300 rounded-md px-2 py-1.5 max-w-[220px] sm:max-w-xs"
>
<option value="">All tenants (aggregated)</option>
{tenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>
{t.domainName
? `${t.domainName} (${t.tenantId.substring(0, 8)}…)`
: t.tenantId}
</option>
))}
</select>
</>
)}
{(["7d", "30d", "90d"] as const).map((range) => (
<button
key={range}
@@ -66,6 +66,11 @@ interface GeographicMetricsResponse {
geoLocationEnabled: boolean;
}
interface TenantInfo {
tenantId: string;
domainName: string;
}
type GroupBy = "city" | "region" | "country";
type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec" | "avgDoPercentPeerCaching";
type TimeRange = "7d" | "30d" | "90d";
@@ -112,19 +117,57 @@ export default function GeographicPerformancePage() {
const [sortDesc, setSortDesc] = useState(true);
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
const hasInitialFetch = useRef(false);
const isTimeRangeMount = useRef(true);
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const { getAccessToken, user } = useAuth();
const { globalAdminMode } = useAdminMode();
// Global admin: tenant selector state (default = own tenant, not aggregated).
const [tenants, setTenants] = useState<TenantInfo[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
const [scopeInitialized, setScopeInitialized] = useState(false);
const isGlobalAdmin = Boolean(globalAdminMode && user?.isGlobalAdmin);
const isAggregatedGlobalView = Boolean(isGlobalAdmin && !selectedTenantId);
const isGlobalOverride = Boolean(
isGlobalAdmin && selectedTenantId && selectedTenantId !== tenantId
);
useEffect(() => {
if (!isGlobalAdmin) return;
const loadTenants = async () => {
try {
const response = await authenticatedFetch(api.config.all(), getAccessToken);
if (response.ok) {
const data = await response.json();
const mapped: TenantInfo[] = data.map((t: { tenantId: string; domainName: string }) => ({
tenantId: t.tenantId,
domainName: t.domainName || "",
}));
mapped.sort((a, b) =>
(a.domainName || a.tenantId).localeCompare(b.domainName || b.tenantId)
);
setTenants(mapped);
}
} catch (err) {
console.error("Error fetching tenant list:", err);
}
};
loadTenants();
}, [isGlobalAdmin, getAccessToken]);
const selectedTenantName = useMemo(
() => tenants.find((t) => t.tenantId === selectedTenantId)?.domainName,
[tenants, selectedTenantId]
);
const fetchGeoMetrics = useCallback(async (range: TimeRange = timeRange, group: GroupBy = groupBy) => {
try {
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const endpoint = globalAdminMode
? api.metrics.globalGeographic(days, group)
const endpoint = isGlobalAdmin
? api.metrics.globalGeographic(days, group, selectedTenantId || undefined)
: api.metrics.geographic(tenantId, days, group);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
@@ -140,23 +183,27 @@ export default function GeographicPerformancePage() {
} finally {
setLoading(false);
}
}, [globalAdminMode, tenantId, getAccessToken, timeRange, groupBy]);
}, [isGlobalAdmin, selectedTenantId, tenantId, getAccessToken, timeRange, groupBy]);
useEffect(() => {
if (!globalAdminMode && !tenantId) return;
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
fetchGeoMetrics();
}, [tenantId, globalAdminMode, fetchGeoMetrics]);
if (scopeInitialized) return;
if (!tenantId) return;
if (isGlobalAdmin) {
setSelectedTenantId(tenantId);
}
setScopeInitialized(true);
}, [tenantId, isGlobalAdmin, scopeInitialized]);
useEffect(() => {
if (!scopeInitialized) return;
if (isTimeRangeMount.current) {
isTimeRangeMount.current = false;
return;
} else {
setLoading(true);
}
setLoading(true);
fetchGeoMetrics(timeRange, groupBy);
}, [timeRange, groupBy, fetchGeoMetrics]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scopeInitialized, timeRange, groupBy, selectedTenantId]);
const sortedLocations = useMemo(() => {
if (!geoMetrics?.locations) return [];
@@ -206,13 +253,20 @@ export default function GeographicPerformancePage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
{globalAdminMode && (
{isGlobalAdmin && (
<div className="bg-purple-700 text-white text-sm px-4 py-2 flex items-center justify-center space-x-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">Global Admin View</span>
<span className="text-purple-300">&mdash; aggregating data across all tenants</span>
<span className="text-purple-300">
&mdash;{" "}
{isAggregatedGlobalView
? "aggregating data across all tenants"
: isGlobalOverride
? `viewing tenant ${selectedTenantName ?? selectedTenantId}`
: "access to all tenants"}
</span>
</div>
)}
<header className="bg-white shadow">
@@ -227,6 +281,25 @@ export default function GeographicPerformancePage() {
</div>
</div>
<div className="flex items-center space-x-4">
{isGlobalAdmin && tenants.length > 0 && (
<div className="flex items-center gap-2">
<label className="text-sm text-gray-500 hidden sm:inline">Tenant:</label>
<select
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
className="text-sm border border-gray-300 rounded-md px-2 py-1.5 max-w-[220px] sm:max-w-xs"
>
<option value="">All tenants (aggregated)</option>
{tenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>
{t.domainName
? `${t.domainName} (${t.tenantId.substring(0, 8)}…)`
: t.tenantId}
</option>
))}
</select>
</div>
)}
{/* Time Range Toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
{(["7d", "30d", "90d"] as TimeRange[]).map((range) => (
@@ -266,7 +339,7 @@ export default function GeographicPerformancePage() {
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* Geo Disabled Warning */}
{geoMetrics && !geoMetrics.geoLocationEnabled && !globalAdminMode && (
{geoMetrics && !geoMetrics.geoLocationEnabled && !isAggregatedGlobalView && (
<div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start space-x-3">
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
@@ -77,7 +77,7 @@ export function SectionSlaTargets() {
placeholder="e.g. 98.0"
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
/>
<p className="text-xs text-gray-400 mt-1">Requires 20+ installs to evaluate</p>
<p className="text-xs text-gray-400 mt-1">Requires 5+ installs per week to evaluate</p>
</div>
</div>
</div>
+4 -4
View File
@@ -134,12 +134,12 @@ export const api = {
`${API_BASE_URL}/api/global/metrics/usage${qs({ tenantId })}`,
app: (tenantId: string, days: number) =>
`${API_BASE_URL}/api/metrics/app${qs({ tenantId, days: String(days) })}`,
globalApp: (days: number) =>
`${API_BASE_URL}/api/global/metrics/app${qs({ days: String(days) })}`,
globalApp: (days: number, tenantId?: string) =>
`${API_BASE_URL}/api/global/metrics/app${qs({ days: String(days), tenantId })}`,
geographic: (tenantId: string, days: number, groupBy: string) =>
`${API_BASE_URL}/api/metrics/geographic${qs({ tenantId, days: String(days), groupBy })}`,
globalGeographic: (days: number, groupBy: string) =>
`${API_BASE_URL}/api/global/metrics/geographic${qs({ days: String(days), groupBy })}`,
globalGeographic: (days: number, groupBy: string, tenantId?: string) =>
`${API_BASE_URL}/api/global/metrics/geographic${qs({ days: String(days), groupBy, tenantId })}`,
geographicSessions: (tenantId: string, days: number, groupBy: string, locationKey: string) =>
`${API_BASE_URL}/api/metrics/geographic/sessions${qs({ tenantId, days: String(days), groupBy, locationKey })}`,
globalGeographicSessions: (days: number, groupBy: string, locationKey: string) =>