fix(metrics): prevent 0.0 falsy bug for sub-hour timing values
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Root cause: avg times were ~2-3 minutes (< 1h). round(0.033, 1) = 0.0
which is falsy in JS, so the frontend showed N/A instead of the value.
Fix (backend): _safe_stats() and team metrics now convert to minutes
when avg < 1 hour, adding a 'unit' field ('min' or 'hrs').
Fix (frontend): use != null instead of truthy check for avg_completion_hours,
MTTD, MTTR — correctly shows 0.0 and uses the unit field to show 'min' or 'hrs'.
This commit is contained in:
@@ -17,13 +17,19 @@ from app.models.enums import TestState, TestResult
|
|||||||
|
|
||||||
|
|
||||||
def _safe_stats(values: list[float]) -> dict:
|
def _safe_stats(values: list[float]) -> dict:
|
||||||
"""Compute mean, median, min, max from a list of floats."""
|
"""Compute mean, median, min, max from a list of floats (in hours).
|
||||||
|
For sub-hour averages, mean_hours is stored as minutes to avoid
|
||||||
|
rounding to 0.0 which is falsy in JavaScript."""
|
||||||
if not values:
|
if not values:
|
||||||
return None
|
return None
|
||||||
sorted_vals = sorted(values)
|
sorted_vals = sorted(values)
|
||||||
n = len(sorted_vals)
|
n = len(sorted_vals)
|
||||||
|
mean = sum(sorted_vals) / n
|
||||||
|
# Use minutes for sub-hour values to avoid JS falsy 0.0
|
||||||
|
mean_display = round(mean * 60, 1) if mean < 1 else round(mean, 1)
|
||||||
return {
|
return {
|
||||||
"mean_hours": round(sum(sorted_vals) / n, 1),
|
"mean_hours": mean_display,
|
||||||
|
"unit": "min" if mean < 1 else "hrs",
|
||||||
"median_hours": round(sorted_vals[n // 2], 1),
|
"median_hours": round(sorted_vals[n // 2], 1),
|
||||||
"min_hours": round(sorted_vals[0], 1),
|
"min_hours": round(sorted_vals[0], 1),
|
||||||
"max_hours": round(sorted_vals[-1], 1),
|
"max_hours": round(sorted_vals[-1], 1),
|
||||||
@@ -416,7 +422,9 @@ def get_metrics_by_team(db: Session) -> dict:
|
|||||||
if net > 0:
|
if net > 0:
|
||||||
red_times.append(net / 3600)
|
red_times.append(net / 3600)
|
||||||
if red_times:
|
if red_times:
|
||||||
red_avg_time = round(sum(red_times) / len(red_times), 1)
|
avg_hours = sum(red_times) / len(red_times)
|
||||||
|
# Use minutes for sub-hour values so rounding to 0.0 doesn't hide data
|
||||||
|
red_avg_time = round(avg_hours * 60, 1) if avg_hours < 1 else round(avg_hours, 1)
|
||||||
|
|
||||||
# Blue team: count tests that reached the blue evaluation phase
|
# Blue team: count tests that reached the blue evaluation phase
|
||||||
blue_tests_completed = (
|
blue_tests_completed = (
|
||||||
@@ -449,17 +457,23 @@ def get_metrics_by_team(db: Session) -> dict:
|
|||||||
if net > 0:
|
if net > 0:
|
||||||
blue_times.append(net / 3600)
|
blue_times.append(net / 3600)
|
||||||
if blue_times:
|
if blue_times:
|
||||||
blue_avg_time = round(sum(blue_times) / len(blue_times), 1)
|
avg_hours = sum(blue_times) / len(blue_times)
|
||||||
|
blue_avg_time = round(avg_hours * 60, 1) if avg_hours < 1 else round(avg_hours, 1)
|
||||||
|
|
||||||
|
red_avg_raw = sum(red_times) / len(red_times) if red_times else None
|
||||||
|
blue_avg_raw = sum(blue_times) / len(blue_times) if blue_times else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"red_team": {
|
"red_team": {
|
||||||
"tests_completed": red_tests_completed,
|
"tests_completed": red_tests_completed,
|
||||||
"avg_completion_hours": red_avg_time,
|
"avg_completion_hours": red_avg_time,
|
||||||
|
"avg_unit": "min" if (red_avg_raw is not None and red_avg_raw < 1) else "hrs",
|
||||||
"rejection_rate": calculate_rejection_rate(db)["by_red_lead"],
|
"rejection_rate": calculate_rejection_rate(db)["by_red_lead"],
|
||||||
},
|
},
|
||||||
"blue_team": {
|
"blue_team": {
|
||||||
"tests_completed": blue_tests_completed,
|
"tests_completed": blue_tests_completed,
|
||||||
"avg_completion_hours": blue_avg_time,
|
"avg_completion_hours": blue_avg_time,
|
||||||
|
"avg_unit": "min" if (blue_avg_raw is not None and blue_avg_raw < 1) else "hrs",
|
||||||
"rejection_rate": calculate_rejection_rate(db)["by_blue_lead"],
|
"rejection_rate": calculate_rejection_rate(db)["by_blue_lead"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,8 +380,8 @@ export default function ExecutiveDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
|
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
|
||||||
<p className="text-lg font-bold text-white">
|
<p className="text-lg font-bold text-white">
|
||||||
{teamMetrics.red_team.avg_completion_hours
|
{teamMetrics.red_team.avg_completion_hours != null
|
||||||
? `${teamMetrics.red_team.avg_completion_hours}h`
|
? `${teamMetrics.red_team.avg_completion_hours}${(teamMetrics.red_team as any).avg_unit ?? "h"}`
|
||||||
: "N/A"}
|
: "N/A"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-gray-500 flex items-center justify-center gap-0.5">Avg Time<MetricTooltip title="Avg Execution Time (Red)" description="Average hours Red Team spends executing and documenting each attack simulation." /></p>
|
<p className="text-[10px] text-gray-500 flex items-center justify-center gap-0.5">Avg Time<MetricTooltip title="Avg Execution Time (Red)" description="Average hours Red Team spends executing and documenting each attack simulation." /></p>
|
||||||
@@ -411,8 +411,8 @@ export default function ExecutiveDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
|
<div className="rounded-lg bg-gray-800 px-3 py-2 text-center">
|
||||||
<p className="text-lg font-bold text-white">
|
<p className="text-lg font-bold text-white">
|
||||||
{teamMetrics.blue_team.avg_completion_hours
|
{teamMetrics.blue_team.avg_completion_hours != null
|
||||||
? `${teamMetrics.blue_team.avg_completion_hours}h`
|
? `${teamMetrics.blue_team.avg_completion_hours}${(teamMetrics.blue_team as any).avg_unit ?? "h"}`
|
||||||
: "N/A"}
|
: "N/A"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-gray-500 flex items-center justify-center gap-0.5">Avg Time<MetricTooltip title="Avg Evaluation Time (Blue)" description="Average hours Blue Team takes to evaluate an attack simulation and document the detection result." /></p>
|
<p className="text-[10px] text-gray-500 flex items-center justify-center gap-0.5">Avg Time<MetricTooltip title="Avg Evaluation Time (Blue)" description="Average hours Blue Team takes to evaluate an attack simulation and document the detection result." /></p>
|
||||||
@@ -573,15 +573,15 @@ export default function ExecutiveDashboardPage() {
|
|||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<KPICard
|
<KPICard
|
||||||
label="MTTD"
|
label="MTTD"
|
||||||
value={opMetrics?.mttd?.mean_hours ?? "N/A"}
|
value={opMetrics?.mttd?.mean_hours != null ? opMetrics.mttd.mean_hours : "N/A"}
|
||||||
unit={opMetrics?.mttd ? "hrs" : undefined}
|
unit={opMetrics?.mttd?.mean_hours != null ? (opMetrics.mttd.mean_hours < 1 ? "min" : "hrs") : undefined}
|
||||||
tooltip={{ description: "Mean Time To Detect — average hours from attack execution to Blue Team raising an alert or detecting the intrusion.", context: "Lower is better. Industry benchmark: < 24h." }}
|
tooltip={{ description: "Mean Time To Detect — average time from attack execution start to Blue Team receiving the test. Shows Red Team execution efficiency.", context: "Lower is better." }}
|
||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
label="MTTR"
|
label="MTTR"
|
||||||
value={opMetrics?.mttr?.mean_hours ?? "N/A"}
|
value={opMetrics?.mttr?.mean_hours != null ? opMetrics.mttr.mean_hours : "N/A"}
|
||||||
unit={opMetrics?.mttr ? "hrs" : undefined}
|
unit={opMetrics?.mttr?.mean_hours != null ? (opMetrics.mttr.mean_hours < 1 ? "min" : "hrs") : undefined}
|
||||||
tooltip={{ description: "Mean Time To Respond — average hours from Red Team attack start to full validation (both Red and Blue leads approved). Measures the total security test cycle time end-to-end.", context: "Lower is better. Long MTTR may indicate bottlenecks in the review pipeline." }}
|
tooltip={{ description: "Mean Time To Respond — average time from Red Team attack start to full validation complete. Measures the total security test cycle time end-to-end.", context: "Lower is better. Long MTTR may indicate bottlenecks in the review pipeline." }}
|
||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
label="Detection Efficacy"
|
label="Detection Efficacy"
|
||||||
|
|||||||
Reference in New Issue
Block a user