mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
consistent switcher everywhere
This commit is contained in:
+5
-1
@@ -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 =>
|
||||
|
||||
+7
-2
@@ -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">— aggregating data across all tenants</span>
|
||||
<span className="text-purple-300">
|
||||
—{" "}
|
||||
{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">— aggregating data across all tenants</span>
|
||||
<span className="text-purple-300">
|
||||
—{" "}
|
||||
{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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user