Update scheduling algorithm

This commit is contained in:
2026-07-23 14:02:14 -04:00
parent 5eebee70d8
commit 23ae3a2f3a
12 changed files with 555 additions and 231 deletions
+40
View File
@@ -0,0 +1,40 @@
# Redesign Scheduler:
Instead of creating a whole schedule at once, schedule tests for today first, then fill in the rest with the original scheduler design. Add a button above remake schedule that opens up a modal and show the following. User has to confirm this new modal before able to click remake schedule.
## Backend:
- Device: keeps track of what device should tests be performed on today.
- DUT priority queue keeps track of:
- rerun required DUT tests
- REF priority queue keeps track of:
- DUT completed tests mirrored (priority 1)
- rerun required REF tests (prirority 2)
- rest of the test bundles in TC
- P2P COE test bundles priority 3
- P2P only test bundles priority 4
- P3P test bundles priority 5
Workflow:
1. fill pending queues:
- fill pending queues with all tests bundles in the current TC
- when there are DUT tests completed last test window, add to REF pending queue with priority 1
- when there are DUT tests rerun required for last test window, add to DUT pending queue with priority 2
- when there are REF tests rerun required for last test window, add to REF pending queue with priority 2
2. Create schedule for today
- Day time testing 9AM - 5PM
- take in user inputs for device and hours(capcity)
- fill in as many test from the chosen device pending queue as possible. If the chosen device pending queue is empty, use the original scheduler knapsack select from active list for that device
- Night time test window
- calculate capacity
- fill in as many tests from the today's device
- if still time available, fill with knapsack select
- if queue empty, fill with knapsack select
## Frontend
- Display DUT pending queue and REF pending queue
- DUT on the left, REF on the right
- list tests
- Display device: if last testing window was DUT then default device should be REF for tonight, vice versa. user has the choice to override this
- Display current Test Config (TC)
- Display day time available hours (default 8 hours)
- Diaplay night time test window available hours (16 hours for workday, 64 for weekends)
+10
View File
@@ -0,0 +1,10 @@
from parser import parse_target_csv
from db import DEVICE_DUT, DEVICE_REF
result = parse_target_csv(['data/CGW453_P2P_COE_Tests.csv'])
by_id = {(t.test_id, t.device): t for t in result.tests}
dut = by_id.get(('P2PTXBE018', DEVICE_DUT))
ref = by_id.get(('COETXBE012', DEVICE_REF))
print('DUT', dut is not None, dut.coe_pairing if dut else None)
print('REF', ref is not None, ref.coe_pairing if ref else None)
+11
View File
@@ -335,6 +335,16 @@ def get_settings() -> dict[str, Any]:
return db.read_settings(DB_PATH) return db.read_settings(DB_PATH)
@app.post("/api/settings/restart")
def restart_and_clear_data() -> dict[str, Any]:
deleted_counts = db.reset_all_data(DB_PATH)
configure_result_watcher({})
return {
"status": "cleared",
**deleted_counts,
}
@app.post("/api/tests/load") @app.post("/api/tests/load")
def load_tests(request: LoadTestsRequest) -> dict[str, Any]: def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
csv_path = _resolve_requested_csv_path(request.csv_path) csv_path = _resolve_requested_csv_path(request.csv_path)
@@ -398,6 +408,7 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
config=t.config, config=t.config,
throttled=t.throttled, throttled=t.throttled,
estimated_minutes=t.estimated_minutes, estimated_minutes=t.estimated_minutes,
status=t.status,
) )
for t in stored_tests for t in stored_tests
] ]
+183 -32
View File
@@ -6,7 +6,7 @@ from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Iterator from typing import Any, Iterator
from test_config import serialize_station_testpoint_map from test_config import serialize_station_testpoint_map, resolve_test_config_keys
APP_ROOT = Path(__file__).resolve().parent APP_ROOT = Path(__file__).resolve().parent
@@ -166,7 +166,6 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
""" """
) )
def _serialize_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> str: def _serialize_station_testpoint_map(config: dict[str, dict[str, str | None]]) -> str:
"""Compute and serialize the station-to-testpoint map from a test config.""" """Compute and serialize the station-to-testpoint map from a test config."""
return serialize_station_testpoint_map(config) return serialize_station_testpoint_map(config)
@@ -377,6 +376,74 @@ def _should_exclude_by_rule(record: TestRecord, tokens: list[str]) -> bool:
return any(_matches_exclusion_token(record, token) for token in tokens) return any(_matches_exclusion_token(record, token) for token in tokens)
def get_completed_test_tcs(db_path: str | Path = DB_PATH) -> set[str]:
"""Return the set of TC names that have at least one completed test."""
with get_connection(db_path) as conn:
rows = conn.execute(
"SELECT config_json FROM tests WHERE status = 'completed' AND excluded = 0"
).fetchall()
tcs: set[str] = set()
for row in rows:
config = json.loads(row["config_json"] or "{}")
tcs.update(resolve_test_config_keys(config))
return tcs
def get_completed_dut_test_ids(db_path: str | Path = DB_PATH) -> set[str]:
"""Return test IDs that have been completed on DUT."""
with get_connection(db_path) as conn:
rows = conn.execute(
"SELECT test_id FROM tests WHERE status = 'completed' AND excluded = 0 AND device = ?",
(DUT,)
).fetchall()
return {row["test_id"] for row in rows}
# Get tests for device that are not excluded regardless of completion status.
def get_not_excluded_tests(device: str, db_path: str | Path = DB_PATH, rules: dict[str, Any] | None = None) -> list[TestRecord]:
tokens = _parse_rule_tokens(rules.get("rule") if rules else "") if rules else []
with get_connection(db_path) as conn:
rows = conn.execute(
"""
SELECT
test_id, device, test_type, rotation, rx_tx, has_coe_pair,
power_mode, config_json, victim_band,
coe_pairing_json, priority, throttled, estimated_minutes,
excluded, status, raw_payload
FROM tests
WHERE excluded = 0
AND device = ?
""",
(device,)
).fetchall()
results: list[TestRecord] = []
for row in rows:
results.append(
TestRecord(
test_id=row["test_id"],
device=row["device"],
test_type=row["test_type"],
rotation=row["rotation"],
rx_tx=row["rx_tx"],
power_mode=row["power_mode"],
has_coe_pair=bool(row["has_coe_pair"]),
coe_pairing=json.loads(row["coe_pairing_json"] or "[]"),
priority=int(row["priority"]),
victim_band=row["victim_band"],
config=json.loads(row["config_json"] or "{}"),
throttled=bool(row["throttled"]),
estimated_minutes=int(row["estimated_minutes"]),
status=row["status"],
excluded=bool(row["excluded"]),
raw_payload=json.loads(row["raw_payload"] or "{}"),
)
)
if not tokens:
return results
return [record for record in results if not _should_exclude_by_rule(record, tokens)]
def list_schedulable_tests(db_path: str | Path = DB_PATH, rule: str = "") -> list[TestRecord]: def list_schedulable_tests(db_path: str | Path = DB_PATH, rule: str = "") -> list[TestRecord]:
tokens = _parse_rule_tokens(rule) tokens = _parse_rule_tokens(rule)
with get_connection(db_path) as conn: with get_connection(db_path) as conn:
@@ -420,6 +487,7 @@ def list_schedulable_tests(db_path: str | Path = DB_PATH, rule: str = "") -> lis
return results return results
return [record for record in results if not _should_exclude_by_rule(record, tokens)] return [record for record in results if not _should_exclude_by_rule(record, tokens)]
# Get all tests for a specific device, regardless of exclusion or completion status. Used for schedule display and management. # Get all tests for a specific device, regardless of exclusion or completion status. Used for schedule display and management.
def list_tests_for_device(device: str, db_path: str | Path = DB_PATH) -> list[TestRecord]: def list_tests_for_device(device: str, db_path: str | Path = DB_PATH) -> list[TestRecord]:
with get_connection(db_path) as conn: with get_connection(db_path) as conn:
@@ -619,7 +687,24 @@ def get_schedule_week(
return [] return []
use_live_status = latest_version is not None and selected_version == latest_version use_live_status = latest_version is not None and selected_version == latest_version
status_sql = "COALESCE(t.status, s.status_snapshot, 'pending')" if use_live_status else "COALESCE(s.status_snapshot, 'pending')" if use_live_status:
# Only surface live 'rerun' for slots that are actually overdue (past).
# Future slots fall back to status_snapshot so they don't show red.
status_sql = """
CASE
WHEN t.status = 'completed' THEN 'completed'
WHEN t.status = 'rerun'
AND (
s.scheduled_date < date('now')
OR (s.scheduled_date = date('now') AND s.shift_index = 1)
)
THEN 'rerun'
WHEN t.status = 'rerun' THEN COALESCE(s.status_snapshot, 'pending')
ELSE COALESCE(t.status, s.status_snapshot, 'pending')
END
"""
else:
status_sql = "COALESCE(s.status_snapshot, 'pending')"
rows = conn.execute( rows = conn.execute(
f""" f"""
@@ -679,7 +764,22 @@ def get_schedule_rows(
return [] return []
use_live_status = latest_version is not None and selected_version == latest_version use_live_status = latest_version is not None and selected_version == latest_version
status_sql = "COALESCE(t.status, s.status_snapshot, 'pending')" if use_live_status else "COALESCE(s.status_snapshot, 'pending')" if use_live_status:
status_sql = """
CASE
WHEN t.status = 'completed' THEN 'completed'
WHEN t.status = 'rerun'
AND (
s.scheduled_date < date('now')
OR (s.scheduled_date = date('now') AND s.shift_index = 1)
)
THEN 'rerun'
WHEN t.status = 'rerun' THEN COALESCE(s.status_snapshot, 'pending')
ELSE COALESCE(t.status, s.status_snapshot, 'pending')
END
"""
else:
status_sql = "COALESCE(s.status_snapshot, 'pending')"
rows = conn.execute( rows = conn.execute(
f""" f"""
@@ -746,21 +846,36 @@ def mark_tests_completed(test_ids_with_device: list[tuple[str, str]], db_path: s
def mark_overdue_as_rerun(db_path: str | Path = DB_PATH) -> int: def mark_overdue_as_rerun(db_path: str | Path = DB_PATH) -> int:
"""Mark tests from the last overnight window that are still pending as rerun.
'Last overnight window' = all pending tests scheduled before today (any shift)
plus today's shift 1 (1am10am) if it has already ended (current hour >= 10).
Returns the number of tests newly marked as rerun.
"""
from datetime import date as _date, datetime as _datetime from datetime import date as _date, datetime as _datetime
now = _datetime.now() now = _datetime.now()
today = now.date().isoformat() today = now.date().isoformat()
shift1_ended = now.hour >= 10 # shift 1 ends ~10am shift1_ended = now.hour >= 10 # shift 1 ends ~10am
with get_connection(db_path) as conn: with get_connection(db_path) as conn:
version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone() # Use the most recent schedule version that has any overdue (past-due) entries.
latest = version_row["latest"] # If a fresh schedule was just compiled, its entries are all in the future and
# MAX(schedule_version) would miss the overdue context from the prior version.
if shift1_ended:
overdue_date_clause = "s.scheduled_date < :today OR (s.scheduled_date = :today AND s.shift_index = 1)"
else:
overdue_date_clause = "s.scheduled_date < :today"
version_row = conn.execute(
f"""
SELECT MAX(s.schedule_version) AS v
FROM schedules s
JOIN tests t ON t.test_id = s.test_id AND t.device = s.device
WHERE t.status = 'pending'
AND ({overdue_date_clause})
""",
{"today": today},
).fetchone()
latest = version_row["v"] if version_row else None
if latest is None:
# No version has overdue pending tests — fall back to MAX to still run the
# update (it will simply mark zero rows).
fb = conn.execute("SELECT MAX(schedule_version) AS v FROM schedules").fetchone()
latest = fb["v"] if fb else None
if latest is None: if latest is None:
return 0 return 0
@@ -807,36 +922,48 @@ def mark_overdue_as_rerun(db_path: str | Path = DB_PATH) -> int:
def get_rerun_tests(db_path: str | Path = DB_PATH) -> list[dict]: def get_rerun_tests(db_path: str | Path = DB_PATH) -> list[dict]:
"""Return tests scheduled before shift 2 today that are not yet completed. """Return all overdue tests that have not been completed.
This checks the latest schedule version and returns tests from: Checks the most recent schedule version that contains overdue pending entries
- Yesterday's shift 3 (5pm-1am) so that recompiling a fresh schedule does not erase the overdue context.
- Today's shift 1 (1am-10am) Overdue = scheduled before today (any shift) or today shift 1 if it has ended.
Once tests are rescheduled to shift 2 or later today, they no longer appear.
""" """
from datetime import date as _date from datetime import date as _date, datetime as _datetime
today = _date.today().isoformat() now = _datetime.now()
today = now.date().isoformat()
shift1_ended = now.hour >= 10
with get_connection(db_path) as conn: with get_connection(db_path) as conn:
version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone() if shift1_ended:
latest = version_row["latest"] overdue_date_clause = "s.scheduled_date < :today OR (s.scheduled_date = :today AND s.shift_index = 1)"
else:
overdue_date_clause = "s.scheduled_date < :today"
version_row = conn.execute(
f"""
SELECT MAX(s.schedule_version) AS v
FROM schedules s
JOIN tests t ON t.test_id = s.test_id AND t.device = s.device
WHERE t.status != 'completed'
AND ({overdue_date_clause})
""",
{"today": today},
).fetchone()
latest = version_row["v"] if version_row else None
if latest is None: if latest is None:
return [] return []
rows = conn.execute( rows = conn.execute(
""" f"""
SELECT DISTINCT t.test_id, t.device, t.test_type, t.estimated_minutes SELECT DISTINCT t.test_id, t.device, t.test_type, t.estimated_minutes
FROM tests t FROM tests t
JOIN schedules s ON s.test_id = t.test_id AND s.device = t.device JOIN schedules s ON s.test_id = t.test_id AND s.device = t.device
WHERE s.schedule_version = ? WHERE s.schedule_version = :v
AND t.status != 'completed' AND t.status != 'completed'
AND ( AND ({overdue_date_clause})
(s.scheduled_date = date(?, '-1 day') AND s.shift_index = 3)
OR (s.scheduled_date = ? AND s.shift_index = 1)
)
ORDER BY t.test_id, t.device ORDER BY t.test_id, t.device
""", """,
(latest, today, today), {"v": latest, "today": today},
).fetchall() ).fetchall()
return [ return [
{ {
@@ -865,3 +992,27 @@ def reset_tests(db_path: str | Path = DB_PATH) -> int:
cursor = conn.execute("DELETE FROM tests") cursor = conn.execute("DELETE FROM tests")
return cursor.rowcount return cursor.rowcount
def reset_all_data(db_path: str | Path = DB_PATH) -> dict[str, int]:
"""Clear all scheduler data from mutable tables.
Keeps schema and runtime default rows intact.
"""
with get_connection(db_path) as conn:
schedules_deleted = conn.execute("DELETE FROM schedules").rowcount
tests_deleted = conn.execute("DELETE FROM tests").rowcount
settings_deleted = conn.execute("DELETE FROM settings").rowcount
holidays_deleted = conn.execute("DELETE FROM holidays").rowcount
# Reset autoincrement counters for cleared tables.
conn.execute(
"DELETE FROM sqlite_sequence WHERE name IN ('tests', 'schedules')"
)
return {
"tests_deleted": tests_deleted,
"schedules_deleted": schedules_deleted,
"settings_deleted": settings_deleted,
"holidays_deleted": holidays_deleted,
}
+3 -3
View File
@@ -536,13 +536,13 @@ def _band_signature(row: dict[str, str], band: str) -> tuple[str, ...] | None:
channel = _pick_value("Channel") channel = _pick_value("Channel")
rssi = _pick_value("RSSI") rssi = _pick_value("RSSI")
bandwidth = _pick_value("Bandwidth") bandwidth = _pick_value("Bandwidth")
direction = _pick_value("Direction") # Pairing should ignore direction so reverse-signed COE and P2P rows still match.
sta = _pick_value("STA") sta = _pick_value("STA")
if not all([test_point, channel, rssi, bandwidth, direction, sta]): if not all([test_point, channel, rssi, bandwidth, sta]):
return None return None
return (test_point, channel, bandwidth, rssi, direction, sta) return (test_point, channel, bandwidth, rssi, sta)
def _all_band_signatures(row: dict[str, str]) -> list[tuple[str, ...]]: def _all_band_signatures(row: dict[str, str]) -> list[tuple[str, ...]]:
+72 -121
View File
@@ -4,7 +4,7 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, timedelta from datetime import date, timedelta
from test_window import get_shift_sequence_with_capacity, get_shift_capacity_for_date, is_off_day, next_window_start_date from test_window import get_shift_sequence_with_capacity, get_shift_capacity_for_date, is_off_day, next_window_start_date
from test_bundle import BUNDLE_PRIORITY_TOP, Test, TestBundle, build_test_bundles, bundle_pair_lookup from test_bundle import Test, TestBundle, build_test_bundles, update_mirrored_bundle_priorities
DUT = (os.getenv("DUT") or "DUT").strip() DUT = (os.getenv("DUT") or "DUT").strip()
REF = (os.getenv("REF") or "REF").strip() REF = (os.getenv("REF") or "REF").strip()
@@ -38,7 +38,7 @@ class Scheduler:
daytime_testing_today: bool = False, daytime_testing_today: bool = False,
dual_device_weekend_start_enabled: bool = False, dual_device_weekend_start_enabled: bool = False,
dual_device_window_start_dates: set[str] | None = None, dual_device_window_start_dates: set[str] | None = None,
priority_weight: int = 100, priority_weight: int = 1000,
): ):
self.top_priority_tests = top_priority_tests self.top_priority_tests = top_priority_tests
self.tests = tests self.tests = tests
@@ -50,11 +50,10 @@ class Scheduler:
self.dual_device_weekend_start_enabled = dual_device_weekend_start_enabled self.dual_device_weekend_start_enabled = dual_device_weekend_start_enabled
self.dual_device_window_start_dates = dual_device_window_start_dates or set() self.dual_device_window_start_dates = dual_device_window_start_dates or set()
self.priority_weight = priority_weight self.priority_weight = priority_weight
self.pending_dut_mirror: list[TestBundle] = []
self.pending_ref_mirror: list[TestBundle] = []
self.schedule: list[ScheduleEntry] = [] self.schedule: list[ScheduleEntry] = []
self.scheduled_bundle_keys: set[tuple[int, str]] = set() self.scheduled_bundle_keys: set[tuple[int, str]] = set()
self.scheduled_test_ids: set[str] = set() self.scheduled_test_ids: set[str] = set()
def compile_schedule(self) -> str | None: def compile_schedule(self) -> str | None:
bundles = build_test_bundles(self.active_dut, self.active_ref, self.top_priority_tests) bundles = build_test_bundles(self.active_dut, self.active_ref, self.top_priority_tests)
@@ -65,80 +64,42 @@ class Scheduler:
print("Compiled Test Bundles:") print("Compiled Test Bundles:")
for bundle in bundles: for bundle in bundles:
print(f"Bundle Index: {bundle.index}, Device: {bundle.device}, Priority: {bundle.priority}, Config: {bundle.config}, Total Minutes: {bundle.total_minutes}, Tests: {bundle.tests}") print(f"Bundle Index: {bundle.index}, Device: {bundle.device}, Priority: {bundle.priority}, Config: {bundle.config}, Total Minutes: {bundle.total_minutes}, Tests: {bundle.tests}")
active_dut_bundles = [b for b in bundles if b.device == DUT]
active_ref_bundles = [b for b in bundles if b.device == REF]
# Group by individual TC values (from config tuples) # Group by individual TC values (from config tuples)
all_tcs = set() all_tcs = set()
for bundle in bundles: for bundle in bundles:
all_tcs.update(bundle.config if bundle.config else [None]) all_tcs.update(bundle.config if bundle.config else [None])
all_bundles_by_tc = self.create_tc_dict(bundles, all_tcs)
# Create dict: TC -> bundles supporting that TC
bundles_by_tc: dict = {}
# Sort with None last
sorted_tcs = sorted([tc for tc in all_tcs if tc is not None]) + ([None] if None in all_tcs else [])
for tc in sorted_tcs:
if tc is None:
bundles_by_tc[tc] = [b for b in bundles if not b.config]
else:
bundles_by_tc[tc] = [b for b in bundles if tc in b.config]
# Sort by priority, then index
bundles_by_tc[tc].sort(key=lambda b: (b.priority, b.index))
window_index = 0 window_index = 0
cursor_date = self.start_date cursor_date = self.start_date
# Process TCs that include top-priority bundles first. tc_order = self.get_tc_order(self.tests)
# REF top-priority TCs come before DUT top-priority TCs so that
# entering tests in the REF top-priority field always claims tonight's
# first window for REF.
def tc_sort_key(item: tuple[str | None, list[TestBundle]]) -> tuple[int, int, int, str]:
tc, tc_bundles = item
has_ref_top = any(bundle.priority == BUNDLE_PRIORITY_TOP and bundle.device == REF for bundle in tc_bundles)
has_dut_top = any(bundle.priority == BUNDLE_PRIORITY_TOP and bundle.device == DUT for bundle in tc_bundles)
min_priority = min((bundle.priority for bundle in tc_bundles), default=BUNDLE_PRIORITY_TOP + 99)
tc_name = tc if tc is not None else "~"
if has_dut_top:
tier = 0 # DUT top-priority TCs first (wins when both exist)
elif has_ref_top:
tier = 1 # REF-only top-priority TCs second
else:
tier = 2 # All other TCs
return (tier, min_priority, -len(tc_bundles), tc_name)
sorted_tc_items = sorted( for tc in tc_order:
[(tc, bundles_by_tc[tc]) for tc in bundles_by_tc], tc_bundles = all_bundles_by_tc[tc]
key=tc_sort_key, if tc_bundles is None or len(tc_bundles) == 0:
) continue
window_device = DUT
pending_dut: list[TestBundle] = []
pending_ref: list[TestBundle] = []
for bundle in tc_bundles:
if bundle.device == DUT:
pending_dut.append(bundle)
for tc, tc_bundles in sorted_tc_items: elif bundle.device == REF:
tc_has_ref_top_priority = any( pending_ref.append(bundle)
bundle.device == REF and bundle.priority == BUNDLE_PRIORITY_TOP
for bundle in tc_bundles
)
tc_has_dut_top_priority = any(
bundle.device == DUT and bundle.priority == BUNDLE_PRIORITY_TOP
for bundle in tc_bundles
)
if tc_has_ref_top_priority: while pending_dut or pending_ref:
window_device = REF active_pending = pending_dut if window_device == DUT else pending_ref
elif tc_has_dut_top_priority: if len(active_pending) == 0:
window_device = DUT
else:
window_device = DUT
dut_unscheduled = [b for b in tc_bundles if b.device == DUT]
ref_unscheduled = [b for b in tc_bundles if b.device == REF]
while dut_unscheduled or ref_unscheduled:
active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled
if len(active_unscheduled) == 0:
# If no unscheduled bundles for the current device, switch to the other device # If no unscheduled bundles for the current device, switch to the other device
window_device = REF if window_device == DUT else DUT window_device = REF if window_device == DUT else DUT
active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled active_pending = pending_dut if window_device == DUT else pending_ref
shifts, capacity = get_shift_sequence_with_capacity(cursor_date, self.holiday_dates, self.daytime_testing_today) shifts, capacity = get_shift_sequence_with_capacity(cursor_date, self.holiday_dates, self.daytime_testing_today)
cursor_date_key = cursor_date.isoformat() cursor_date_key = cursor_date.isoformat()
@@ -146,60 +107,20 @@ class Scheduler:
if not dual_device_window and self.dual_device_weekend_start_enabled: if not dual_device_window and self.dual_device_weekend_start_enabled:
dual_device_window = self._is_weekend_start_day(cursor_date) dual_device_window = self._is_weekend_start_day(cursor_date)
# Evaluate both devices without mutating queue state, then commit once. selected_bundles = []
mirrored_bundles, knapsack_bundles, remaining_time = self._select_bundles( selected_bundles = self._knapsack_select(capacity, active_pending)
window_device, remaining_time = capacity - sum(bundle.total_minutes for bundle in selected_bundles)
capacity,
active_unscheduled,
tc,
mutate=True,
)
selected_bundles = mirrored_bundles + knapsack_bundles
if len(selected_bundles) == 0:
window_device = REF if window_device == DUT else DUT
active_unscheduled = dut_unscheduled if window_device == DUT else ref_unscheduled
mirrored_bundles, knapsack_bundles, remaining_time = self._select_bundles(
window_device,
capacity,
active_unscheduled,
tc,
mutate=True,
)
selected_bundles = mirrored_bundles + knapsack_bundles
if len(selected_bundles) == 0:
break
primary_device = window_device primary_device = window_device
if dual_device_window and remaining_time > 0: if dual_device_window and remaining_time > 0:
secondary_device = REF if primary_device == DUT else DUT secondary_device = REF if primary_device == DUT else DUT
secondary_unscheduled = dut_unscheduled if secondary_device == DUT else ref_unscheduled secondary_pending = pending_dut if secondary_device == DUT else pending_ref
secondary_mirrored, secondary_knapsack, remaining_time = self._select_bundles( secondary_selected_bundles = self._knapsack_select(remaining_time, secondary_pending)
secondary_device, selected_bundles.extend(secondary_selected_bundles)
remaining_time,
secondary_unscheduled,
tc,
mutate=True,
)
selected_bundles.extend(secondary_mirrored + secondary_knapsack)
# Create a mirror of the other device for the next window
bundle_pairs = bundle_pair_lookup(knapsack_bundles, dut_unscheduled, ref_unscheduled)
if window_device == DUT:
pending_target = self.pending_ref_mirror
else:
pending_target = self.pending_dut_mirror
existing_pending = {(b.index, b.device) for b in pending_target}
selected_bundle_keys = {(b.index, b.device) for b in selected_bundles}
for pair_bundle in bundle_pairs:
pair_key = (pair_bundle.index, pair_bundle.device)
if pair_key in self.scheduled_bundle_keys or pair_key in existing_pending or pair_key in selected_bundle_keys:
continue
pending_target.append(pair_bundle)
existing_pending.add(pair_key)
# Create a mirror of the other device for the next window by updating the priorities
pending_dut, pending_ref = update_mirrored_bundle_priorities(selected_bundles, pending_dut, pending_ref)
window = ScheduleWindow( window = ScheduleWindow(
index=window_index, index=window_index,
@@ -216,12 +137,12 @@ class Scheduler:
selected_keys = {(b.index, b.device) for b in selected_bundles} selected_keys = {(b.index, b.device) for b in selected_bundles}
# Remove scheduled bundles from ALL TC buckets globally # Remove scheduled bundles from ALL TC buckets globally
for all_tc in bundles_by_tc: for all_tc in all_bundles_by_tc:
bundles_by_tc[all_tc] = [b for b in bundles_by_tc[all_tc] if (b.index, b.device) not in selected_keys] all_bundles_by_tc[all_tc] = [b for b in all_bundles_by_tc[all_tc] if (b.index, b.device) not in selected_keys]
# Rebuild current TC unscheduled lists # Remove from pending queues
dut_unscheduled = [b for b in bundles_by_tc[tc] if b.device == DUT] pending_dut = [b for b in pending_dut if (b.index, b.device) not in selected_keys]
ref_unscheduled = [b for b in bundles_by_tc[tc] if b.device == REF] pending_ref = [b for b in pending_ref if (b.index, b.device) not in selected_keys]
window_index += 1 window_index += 1
window_device = REF if window_device == DUT else DUT window_device = REF if window_device == DUT else DUT
@@ -243,7 +164,7 @@ class Scheduler:
mutate: bool, mutate: bool,
) -> tuple[list[TestBundle], list[TestBundle], int]: ) -> tuple[list[TestBundle], list[TestBundle], int]:
# Consume pending mirrored bundles only while there is room. # Consume pending mirrored bundles only while there is room.
pending = self.pending_dut_mirror if device == DUT else self.pending_ref_mirror pending = self.pending_dut if device == DUT else self.pending_ref
mirrored_bundles: list[TestBundle] = [] mirrored_bundles: list[TestBundle] = []
still_pending: list[TestBundle] = [] # Bundles that couldn't fit in the remaining capacity still_pending: list[TestBundle] = [] # Bundles that couldn't fit in the remaining capacity
remaining_capacity = capacity remaining_capacity = capacity
@@ -288,11 +209,13 @@ class Scheduler:
) -> list[TestBundle]: ) -> list[TestBundle]:
if capacity <= 0 or not candidates: if capacity <= 0 or not candidates:
return [] return []
# Weights are total minutes of each bundle # Weights are total minutes of each bundle
weights = [bundle.total_minutes for bundle in candidates] weights = [bundle.total_minutes for bundle in candidates]
# Values are based on priority, lower priority number means higher value # Values strongly favor higher priority tiers without hard-forcing them.
values = [self.priority_weight - bundle.priority for bundle in candidates] max_priority = max(bundle.priority for bundle in candidates)
priority_bias = 2.5
values = [int(self.priority_weight * (priority_bias ** (max_priority - bundle.priority))) for bundle in candidates]
# Implement dynamic programming knapsack algorithm to select bundles # Implement dynamic programming knapsack algorithm to select bundles
n = len(candidates) n = len(candidates)
@@ -387,6 +310,34 @@ class Scheduler:
if device == REF and test_id in self.active_ref: if device == REF and test_id in self.active_ref:
return self.active_ref[test_id].estimated_minutes return self.active_ref[test_id].estimated_minutes
return 0 return 0
def create_tc_dict(self, bundles: list[TestBundle], all_tcs: set[str | None]) -> dict[str | None, list[TestBundle]]:
# Create dict: TC -> bundles supporting that TC
all_bundles_by_tc: dict[str | None, list[TestBundle]] = {}
# Sort with None last
sorted_tcs = sorted([tc for tc in all_tcs if tc is not None]) + ([None] if None in all_tcs else [])
for tc in sorted_tcs:
if tc is None:
all_bundles_by_tc[tc] = [b for b in bundles if not b.config]
else:
all_bundles_by_tc[tc] = [b for b in bundles if tc in b.config]
# Sort by priority, then index
all_bundles_by_tc[tc].sort(key=lambda b: (b.priority, b.index))
return all_bundles_by_tc
def get_tc_order(self, all_tests: list[Test]) -> list[str | None]:
# Implement the logic for getting the test case order
all_dut = {test.test_id: test for test in all_tests if test.device == DUT}
all_ref = {test.test_id: test for test in all_tests if test.device == REF}
bundles = build_test_bundles(all_dut, all_ref, [])
all_tcs = set()
for bundle in bundles:
all_tcs.update(bundle.config if bundle.config else [None])
all_bundles_by_tc = self.create_tc_dict(bundles, all_tcs)
# Sort TCs by the number of bundles available for each TC (most to least)
sorted_tcs = sorted(all_bundles_by_tc.keys(), key=lambda tc: len(all_bundles_by_tc[tc]), reverse=True)
return sorted_tcs
+95 -70
View File
@@ -4,8 +4,7 @@ from dataclasses import dataclass
import os import os
from test_config import build_bundle_test_configs from test_config import build_bundle_test_configs
@dataclass(frozen=False)
@dataclass(frozen=True)
class Test: class Test:
test_id: str test_id: str
device: str device: str
@@ -17,18 +16,22 @@ class Test:
config: dict[str, dict[str, str | None]] config: dict[str, dict[str, str | None]]
throttled: bool throttled: bool
estimated_minutes: int estimated_minutes: int
status: str
# Bundle priority tiers for scheduling order (lower number = higher priority) # Bundle priority tiers for scheduling order (lower number = higher priority)
BUNDLE_PRIORITY_TOP = 0 # Failed tests requiring rerun BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR = 0 # REF-side tests whose DUT counterpart is already completed
BUNDLE_PRIORITY_P2P_WITH_COE = 1 # P2P tests with COE pairs BUNDLE_PRIORITY_USER_TOP = 1 # User-specified top priority tests
BUNDLE_PRIORITY_P2P_ONLY = 2 # P2P tests without COE pairs (RX/TX bundled) BUNDLE_PRIORITY_RERUN = 2 # Rerun tests (DUT or REF)
BUNDLE_PRIORITY_COE_ONLY = 3 # COE tests without P2P pairing (should be rare/unschedulable) BUNDLE_PRIORITY_MIRROR = 3
BUNDLE_PRIORITY_P3P = 4 # P3P tests BUNDLE_PRIORITY_P2P_WITH_COE = 4 # P2P tests with COE pairs
BUNDLE_PRIORITY_P2P_ONLY = 5 # P2P tests without COE pairs (RX/TX bundled)
BUNDLE_PRIORITY_COE_ONLY = 6 # COE tests without P2P pairing (should be rare/unschedulable)
BUNDLE_PRIORITY_P3P = 6 # P3P tests
DUT = (os.getenv("DUT") or "DUT").strip() DUT = (os.getenv("DUT") or "DUT").strip()
REF = (os.getenv("REF") or "REF").strip() REF = (os.getenv("REF") or "REF").strip()
@dataclass(frozen=True) @dataclass(frozen=False)
class TestBundle: class TestBundle:
index: int index: int
tests: list[str] tests: list[str]
@@ -36,7 +39,8 @@ class TestBundle:
device: str device: str
config: tuple[str, ...] config: tuple[str, ...]
priority: int priority: int
completed: int
def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test], top_priority_tests: set[tuple[str, str]]) -> list[TestBundle]: def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test], top_priority_tests: set[tuple[str, str]]) -> list[TestBundle]:
"""Build deterministic bundles for DP scheduling.""" """Build deterministic bundles for DP scheduling."""
@@ -55,25 +59,6 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
deduped.append(test_id) deduped.append(test_id)
return deduped return deduped
dut_top_priority: list[str] = []
ref_top_priority: list[str] = []
for test_id, device in top_priority_tests:
if device == DUT and test_id in active_dut:
dut_top_priority.append(test_id)
elif device == REF and test_id in active_ref:
ref_top_priority.append(test_id)
if dut_top_priority:
test_bundles.append(create_bundle(dut_top_priority, DUT, BUNDLE_PRIORITY_TOP, bundle_index, active_dut, active_ref))
processed_dut.update(dut_top_priority)
bundle_index += 1
if ref_top_priority:
test_bundles.append(create_bundle(ref_top_priority, REF, BUNDLE_PRIORITY_TOP, bundle_index, active_dut, active_ref))
processed_ref.update(ref_top_priority)
bundle_index += 1
# Phase 1: DUT P2P bundles # Phase 1: DUT P2P bundles
for dut_test_id, dut_test in active_dut.items(): for dut_test_id, dut_test in active_dut.items():
if dut_test_id in processed_dut or dut_test.test_type != "P2P": if dut_test_id in processed_dut or dut_test.test_type != "P2P":
@@ -100,6 +85,15 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
priority = BUNDLE_PRIORITY_P2P_ONLY priority = BUNDLE_PRIORITY_P2P_ONLY
dut_bundled_tests = dedupe_preserve_order(dut_bundled_tests) dut_bundled_tests = dedupe_preserve_order(dut_bundled_tests)
# Check if any of the DUT tests in this bundle are in the top priority list
if any((DUT, dut_test_id) in top_priority_tests for dut_test_id in dut_bundled_tests):
priority = BUNDLE_PRIORITY_USER_TOP
# Check if any of the DUT tests in this bundle are reruns
if any((dut_test.status == "rerun" for dut_test_id in dut_bundled_tests if (dut_test := active_dut.get(dut_test_id)))):
priority = BUNDLE_PRIORITY_RERUN
test_bundles.append( test_bundles.append(
create_bundle(dut_bundled_tests, DUT, priority, bundle_index, active_dut, active_ref) create_bundle(dut_bundled_tests, DUT, priority, bundle_index, active_dut, active_ref)
) )
@@ -112,6 +106,14 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
if ref_bundled_tests: if ref_bundled_tests:
ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests) ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
processed_ref.update(ref_bundled_tests) processed_ref.update(ref_bundled_tests)
# Check if any of the REF tests in this bundle are in the top priority list
if any((REF, ref_test_id) in top_priority_tests for ref_test_id in ref_bundled_tests):
priority = BUNDLE_PRIORITY_USER_TOP
# Check if any of the REF tests in this bundle are reruns
if any((ref_test.status == "rerun" for ref_test_id in ref_bundled_tests if (ref_test := active_ref.get(ref_test_id)))):
priority = BUNDLE_PRIORITY_RERUN
test_bundles.append( test_bundles.append(
create_bundle(ref_bundled_tests, REF, priority, bundle_index, active_dut, active_ref) create_bundle(ref_bundled_tests, REF, priority, bundle_index, active_dut, active_ref)
) )
@@ -132,8 +134,18 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
processed_dut.add(th_ut_pair_id) processed_dut.add(th_ut_pair_id)
dut_bundled_tests = dedupe_preserve_order(dut_bundled_tests) dut_bundled_tests = dedupe_preserve_order(dut_bundled_tests)
priority = BUNDLE_PRIORITY_P3P
# Check if any of the DUT tests in this bundle are in the top priority list
if any((DUT, dut_test_id) in top_priority_tests for dut_test_id in dut_bundled_tests):
priority = BUNDLE_PRIORITY_USER_TOP
# Check if any of the DUT tests in this bundle are reruns
if any((dut_test.status == "rerun" for dut_test_id in dut_bundled_tests if (dut_test := active_dut.get(dut_test_id)))):
priority = BUNDLE_PRIORITY_RERUN
test_bundles.append( test_bundles.append(
create_bundle(dut_bundled_tests, DUT, BUNDLE_PRIORITY_P3P, bundle_index, active_dut, active_ref) create_bundle(dut_bundled_tests, DUT, priority, bundle_index, active_dut, active_ref)
) )
ref_bundled_tests = [ ref_bundled_tests = [
@@ -141,49 +153,66 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
for test_id in dut_bundled_tests for test_id in dut_bundled_tests
if test_id in active_ref and test_id not in processed_ref if test_id in active_ref and test_id not in processed_ref
] ]
# Check if any of the REF tests in this bundle are in the top priority list
if any((REF, ref_test_id) in top_priority_tests for ref_test_id in ref_bundled_tests):
priority = BUNDLE_PRIORITY_USER_TOP
# Check if any of the REF tests in this bundle are reruns
if any((ref_test.status == "rerun" for ref_test_id in ref_bundled_tests if (ref_test := active_ref.get(ref_test_id)))):
priority = BUNDLE_PRIORITY_RERUN
if ref_bundled_tests: if ref_bundled_tests:
ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests) ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
processed_ref.update(ref_bundled_tests) processed_ref.update(ref_bundled_tests)
test_bundles.append( test_bundles.append(
create_bundle(ref_bundled_tests, REF, BUNDLE_PRIORITY_P3P, bundle_index, active_dut, active_ref) create_bundle(ref_bundled_tests, REF, priority, bundle_index, active_dut, active_ref)
) )
bundle_index += 1 bundle_index += 1
# Phase 3: DUT leftovers (including COE-only) # Phase 3: DUT leftovers (COE-only)
for dut_test_id in active_dut: for dut_test_id in active_dut:
if dut_test_id in processed_dut: if dut_test_id in processed_dut:
continue continue
print(f"Orphan COE-only DUT test found: {dut_test_id}")
test_bundles.append( test_bundles.append(
create_bundle([dut_test_id], DUT, BUNDLE_PRIORITY_COE_ONLY, bundle_index, active_dut, active_ref) create_bundle([dut_test_id], DUT, BUNDLE_PRIORITY_COE_ONLY, bundle_index, active_dut, active_ref)
) )
bundle_index += 1 bundle_index += 1
processed_dut.add(dut_test_id) processed_dut.add(dut_test_id)
# Phase 4: unmatched REF P2P bundles # Phase 4: unmatched REF bundles, meaning DUT is completed
for ref_test_id, ref_test in active_ref.items(): for ref_test_id, ref_test in active_ref.items():
if ref_test_id in processed_ref or ref_test.test_type != "P2P": if ref_test_id in processed_ref:
continue continue
ref_bundled_tests = [ref_test_id] ref_bundled_tests = [ref_test_id]
processed_ref.add(ref_test_id) processed_ref.add(ref_test_id)
active_coe_pairings = [ if ref_test.test_type == "P2P":
test_id active_coe_pairings = [
for test_id in ref_test.coe_pairing test_id
if test_id in active_ref and test_id not in processed_ref for test_id in ref_test.coe_pairing
] if test_id in active_ref and test_id not in processed_ref
]
if active_coe_pairings: if active_coe_pairings:
ref_bundled_tests.extend(active_coe_pairings) ref_bundled_tests.extend(active_coe_pairings)
processed_ref.update(active_coe_pairings) processed_ref.update(active_coe_pairings)
priority = BUNDLE_PRIORITY_P2P_WITH_COE priority = BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR
else: else:
rx_tx_pair_id = get_rx_tx_pair_id(ref_test, REF, active_dut, active_ref) rx_tx_pair_id = get_rx_tx_pair_id(ref_test, REF, active_dut, active_ref)
if rx_tx_pair_id and rx_tx_pair_id not in processed_ref: if rx_tx_pair_id and rx_tx_pair_id not in processed_ref:
ref_bundled_tests.append(rx_tx_pair_id) ref_bundled_tests.append(rx_tx_pair_id)
processed_ref.add(rx_tx_pair_id) processed_ref.add(rx_tx_pair_id)
priority = BUNDLE_PRIORITY_P2P_ONLY priority = BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR
if ref_test.test_type == "P3P":
th_ut_pair_id = get_th_ut_pair_id(ref_test, REF, active_dut, active_ref)
if th_ut_pair_id and th_ut_pair_id not in processed_ref:
ref_bundled_tests.append(th_ut_pair_id)
processed_ref.add(th_ut_pair_id)
priority = BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR
ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests) ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
test_bundles.append( test_bundles.append(
@@ -191,31 +220,13 @@ def build_test_bundles(active_dut: dict[str, Test], active_ref: dict[str, Test],
) )
bundle_index += 1 bundle_index += 1
# Phase 5: unmatched REF P3P bundles # Phase 5: REF leftovers ( COE-only)
for ref_test_id, ref_test in active_ref.items():
if ref_test_id in processed_ref or ref_test.test_type != "P3P":
continue
ref_bundled_tests = [ref_test_id]
processed_ref.add(ref_test_id)
th_ut_pair_id = get_th_ut_pair_id(ref_test, REF, active_dut, active_ref)
if th_ut_pair_id and th_ut_pair_id not in processed_ref:
ref_bundled_tests.append(th_ut_pair_id)
processed_ref.add(th_ut_pair_id)
ref_bundled_tests = dedupe_preserve_order(ref_bundled_tests)
test_bundles.append(
create_bundle(ref_bundled_tests, REF, BUNDLE_PRIORITY_P3P, bundle_index, active_dut, active_ref)
)
bundle_index += 1
# Phase 6: REF leftovers (including COE-only)
for ref_test_id in active_ref: for ref_test_id in active_ref:
if ref_test_id in processed_ref: if ref_test_id in processed_ref:
continue continue
print(f"Orphan COE-only REF test found: {ref_test_id}")
test_bundles.append( test_bundles.append(
create_bundle([ref_test_id], REF, BUNDLE_PRIORITY_COE_ONLY, bundle_index, active_dut, active_ref) create_bundle([ref_test_id], REF, BUNDLE_PRIORITY_DUT_COMPLETED_MIRROR, bundle_index, active_dut, active_ref)
) )
bundle_index += 1 bundle_index += 1
processed_ref.add(ref_test_id) processed_ref.add(ref_test_id)
@@ -256,6 +267,7 @@ def create_bundle(tests: list[str], device: str, priority: int, index: int, acti
device=device, device=device,
config=config, config=config,
priority=priority, priority=priority,
completed=0
) )
def bundle_pair_lookup(bundles: list[TestBundle], dut_bundles: list[TestBundle], ref_bundles: list[TestBundle]) -> list[TestBundle]: def bundle_pair_lookup(bundles: list[TestBundle], dut_bundles: list[TestBundle], ref_bundles: list[TestBundle]) -> list[TestBundle]:
@@ -267,4 +279,17 @@ def bundle_pair_lookup(bundles: list[TestBundle], dut_bundles: list[TestBundle],
return [b for b in ref_bundles if b.index in bundle_indexes] return [b for b in ref_bundles if b.index in bundle_indexes]
elif device == REF: elif device == REF:
return [b for b in dut_bundles if b.index in bundle_indexes] return [b for b in dut_bundles if b.index in bundle_indexes]
return [] return []
def update_mirrored_bundle_priorities(knapsack_bundles: list[TestBundle], dut_pending: list[TestBundle], ref_pending: list[TestBundle]) -> tuple[list[TestBundle], list[TestBundle]]:
bundle_pairs = bundle_pair_lookup(knapsack_bundles, dut_pending, ref_pending)
for bundle in dut_pending:
if bundle.index in {b.index for b in bundle_pairs}:
bundle.priority = BUNDLE_PRIORITY_MIRROR
for bundle in ref_pending:
if bundle.index in {b.index for b in bundle_pairs}:
bundle.priority = BUNDLE_PRIORITY_MIRROR
return dut_pending, ref_pending
+30
View File
@@ -402,6 +402,35 @@ export default function App() {
} }
} }
async function handleRestartAndClearData() {
setLoading(true)
setError(null)
try {
await api.restartAndClearData()
setSettings(DEFAULT_SETTINGS)
setTopPriorityDut('')
setTopPriorityRef('')
setLowestPriority('')
setStartDateOverride('')
setFailedTests([])
setScheduleData({})
setPreviousScheduleData({})
setScheduleStartShift(null)
setScheduleWindows([])
setSelectedWindowId(null)
setCompletionDate(null)
await fetchSchedule(weekStart)
await fetchRerunTests()
} catch (e) {
setError(e.message)
throw e
} finally {
setLoading(false)
}
}
async function handleRemakeSchedule() { async function handleRemakeSchedule() {
setLoading(true) setLoading(true)
setError(null) setError(null)
@@ -541,6 +570,7 @@ export default function App() {
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
settings={settings} settings={settings}
onSave={handleSaveSettings} onSave={handleSaveSettings}
onRestart={handleRestartAndClearData}
/> />
</div> </div>
) )
+1
View File
@@ -20,6 +20,7 @@ export const api = {
// Settings // Settings
getSettings: () => request('GET', '/settings'), getSettings: () => request('GET', '/settings'),
saveSettings: (settings) => request('POST', '/settings', { settings }), saveSettings: (settings) => request('POST', '/settings', { settings }),
restartAndClearData: () => request('POST', '/settings/restart'),
// Holidays // Holidays
getHolidays: () => request('GET', '/holidays'), getHolidays: () => request('GET', '/holidays'),
+17 -3
View File
@@ -5,7 +5,17 @@ export default function FailedBanner({ rerunTests, onDecision }) {
const hours = Math.floor(totalMinutes / 60) const hours = Math.floor(totalMinutes / 60)
const mins = totalMinutes % 60 const mins = totalMinutes % 60
const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m` const timeStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
const testIds = rerunTests.map(t => t.test_id).join(', ')
// Group test IDs by device
const byDevice = rerunTests.reduce((acc, t) => {
const device = t.device ?? 'Unknown'
if (!acc[device]) acc[device] = []
acc[device].push(t.test_id)
return acc
}, {})
const deviceSummaries = Object.entries(byDevice)
.sort(([a], [b]) => a.localeCompare(b))
.map(([device, ids]) => `${device}: ${ids.join(', ')}`)
return ( return (
<div className="flex items-start gap-4 px-6 py-3 bg-red-900/70 border-b border-red-700 text-red-100"> <div className="flex items-start gap-4 px-6 py-3 bg-red-900/70 border-b border-red-700 text-red-100">
@@ -26,9 +36,13 @@ export default function FailedBanner({ rerunTests, onDecision }) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
<span className="font-semibold">{rerunTests.length} test{rerunTests.length !== 1 ? 's' : ''}</span> <span className="font-semibold">{rerunTests.length} test{rerunTests.length !== 1 ? 's' : ''}</span>
{' '}require a rerun not completed in last overnight window:{' '} {' '}require a rerun not completed in last overnight window:
<span className="font-mono text-red-200">{testIds}</span>
</p> </p>
{deviceSummaries.map((summary) => (
<p key={summary} className="text-sm font-mono text-red-200 mt-0.5 truncate">
{summary}
</p>
))}
<p className="text-sm text-red-300 mt-0.5"> <p className="text-sm text-red-300 mt-0.5">
Estimated rerun time: <span className="font-semibold text-red-100">{timeStr}</span> Estimated rerun time: <span className="font-semibold text-red-100">{timeStr}</span>
{' '} Schedule rerun during daytime today? {' '} Schedule rerun during daytime today?
+93 -2
View File
@@ -16,12 +16,22 @@ function Field({ label, hint, required = false, children }) {
const INPUT_CLS = const INPUT_CLS =
'w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono' 'w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono'
export default function SettingsModal({ isOpen, onClose, settings, onSave }) { export default function SettingsModal({ isOpen, onClose, settings, onSave, onRestart }) {
const [form, setForm] = useState({ ...settings }) const [form, setForm] = useState({ ...settings })
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [restartBusy, setRestartBusy] = useState(false)
const [restartError, setRestartError] = useState(null)
// Sync if parent settings change while modal is closed // Sync if parent settings change while modal is closed
useEffect(() => { useEffect(() => {
if (!isOpen) setForm({ ...settings }) if (!isOpen) {
setForm({ ...settings })
setConfirmOpen(false)
setConfirmText('')
setRestartBusy(false)
setRestartError(null)
}
}, [isOpen, settings]) }, [isOpen, settings])
if (!isOpen) return null if (!isOpen) return null
@@ -35,6 +45,30 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
onClose() onClose()
} }
async function handleRestartClick() {
if (!confirmOpen) {
setConfirmOpen(true)
setRestartError(null)
return
}
if (confirmText.trim() !== 'RESET') {
setRestartError('Type RESET to confirm database clear.')
return
}
setRestartBusy(true)
setRestartError(null)
try {
await onRestart()
onClose()
} catch (err) {
setRestartError(err?.message || 'Failed to restart and clear data.')
} finally {
setRestartBusy(false)
}
}
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -263,6 +297,63 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
/> />
</Field> </Field>
</div> </div>
<hr className="border-gray-700" />
{/* Section: Danger Zone */}
<div>
<p className="text-[11px] font-bold uppercase tracking-widest text-red-400 mb-3">
Danger Zone
</p>
<div className="rounded-lg border border-red-800 bg-red-950/30 p-3">
<p className="text-sm text-red-200 font-semibold">Reset And Clear Database</p>
<p className="mt-1 text-xs text-red-300/90">
This permanently deletes tests, schedules, settings, and holidays from the database.
</p>
{confirmOpen && (
<div className="mt-3 flex flex-col gap-2">
<p className="text-xs text-red-200">
Type <span className="font-semibold">RESET</span> below, then click the button again to confirm.
</p>
<input
type="text"
className={INPUT_CLS}
placeholder="Type RESET"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
/>
</div>
)}
{restartError && (
<p className="mt-2 text-xs text-red-300">{restartError}</p>
)}
<div className="mt-3 flex gap-2">
<button
onClick={handleRestartClick}
disabled={restartBusy}
className="px-3 py-1.5 text-sm font-semibold text-white bg-red-700 hover:bg-red-600 border border-red-500 rounded-md transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{restartBusy ? 'Clearing...' : confirmOpen ? 'Confirm Clear DB' : 'Reset'}
</button>
{confirmOpen && (
<button
onClick={() => {
setConfirmOpen(false)
setConfirmText('')
setRestartError(null)
}}
disabled={restartBusy}
className="px-3 py-1.5 text-sm text-gray-300 bg-gray-800 border border-gray-600 rounded-md hover:bg-gray-700 transition-colors disabled:opacity-60"
>
Cancel
</button>
)}
</div>
</div>
</div>
</div> </div>
{/* Footer */} {/* Footer */}