implemented watcher and rerun logic
This commit is contained in:
+66
-3
@@ -12,6 +12,7 @@ import db
|
|||||||
import graph
|
import graph
|
||||||
from parser import CsvValidationError, parse_target_csv
|
from parser import CsvValidationError, parse_target_csv
|
||||||
from scheduler import SchedulerTest, compile_schedule, remove_from_active, reset_scheduler_state
|
from scheduler import SchedulerTest, compile_schedule, remove_from_active, reset_scheduler_state
|
||||||
|
from watcher import configure_result_watcher, stop_result_watcher
|
||||||
|
|
||||||
|
|
||||||
APP_ROOT = Path(__file__).resolve().parent
|
APP_ROOT = Path(__file__).resolve().parent
|
||||||
@@ -28,6 +29,41 @@ class SaveSettingsRequest(BaseModel):
|
|||||||
settings: dict[str, Any]
|
settings: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_credentials_from_settings(settings: dict[str, Any]) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"username": str(settings.get("smbUsername") or "").strip(),
|
||||||
|
"password": str(settings.get("smbPassword") or ""),
|
||||||
|
"domain": str(settings.get("smbDomain") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_overrides_from_settings(settings: dict[str, Any]) -> dict[str, int]:
|
||||||
|
def _parse_positive_int(value: Any) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return parsed if parsed > 0 else None
|
||||||
|
|
||||||
|
overrides: dict[str, int] = {}
|
||||||
|
for test_type, key in (
|
||||||
|
("P2P", "p2pRuntimeMinutes"),
|
||||||
|
("COE", "coeRuntimeMinutes"),
|
||||||
|
("P3P", "p3pRuntimeMinutes"),
|
||||||
|
):
|
||||||
|
minutes = _parse_positive_int(settings.get(key))
|
||||||
|
if minutes is not None:
|
||||||
|
overrides[test_type] = minutes
|
||||||
|
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
class CompileScheduleRequest(BaseModel):
|
class CompileScheduleRequest(BaseModel):
|
||||||
start_date: str | None = Field(default=None, description="YYYY-MM-DD")
|
start_date: str | None = Field(default=None, description="YYYY-MM-DD")
|
||||||
rule: str = ""
|
rule: str = ""
|
||||||
@@ -48,7 +84,12 @@ class SaveHolidaysRequest(BaseModel):
|
|||||||
async def lifespan(application: FastAPI):
|
async def lifespan(application: FastAPI):
|
||||||
db.init_db(DB_PATH)
|
db.init_db(DB_PATH)
|
||||||
graph.reset_graph_state()
|
graph.reset_graph_state()
|
||||||
|
settings = db.read_settings(DB_PATH)
|
||||||
|
configure_result_watcher(settings)
|
||||||
|
try:
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
|
stop_result_watcher()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Scheduler API", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title="Scheduler API", version="0.1.0", lifespan=lifespan)
|
||||||
@@ -69,6 +110,7 @@ def health() -> dict[str, str]:
|
|||||||
@app.post("/api/settings")
|
@app.post("/api/settings")
|
||||||
def save_settings(request: SaveSettingsRequest) -> dict[str, str]:
|
def save_settings(request: SaveSettingsRequest) -> dict[str, str]:
|
||||||
db.save_settings(request.settings, DB_PATH)
|
db.save_settings(request.settings, DB_PATH)
|
||||||
|
configure_result_watcher(db.read_settings(DB_PATH))
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
@@ -83,8 +125,16 @@ def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
|
|||||||
if not csv_path.is_absolute():
|
if not csv_path.is_absolute():
|
||||||
csv_path = APP_ROOT / csv_path
|
csv_path = APP_ROOT / csv_path
|
||||||
|
|
||||||
|
settings = db.read_settings(DB_PATH)
|
||||||
|
smb_credentials = _smb_credentials_from_settings(settings)
|
||||||
|
runtime_overrides = _runtime_overrides_from_settings(settings)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed = parse_target_csv(csv_path)
|
parsed = parse_target_csv(
|
||||||
|
csv_path,
|
||||||
|
smb_credentials=smb_credentials,
|
||||||
|
runtime_overrides=runtime_overrides,
|
||||||
|
)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except CsvValidationError as exc:
|
except CsvValidationError as exc:
|
||||||
@@ -94,7 +144,7 @@ def load_tests(request: LoadTestsRequest) -> dict[str, Any]:
|
|||||||
reset_scheduler_state()
|
reset_scheduler_state()
|
||||||
graph.reset_graph_state()
|
graph.reset_graph_state()
|
||||||
all_dut_tests = db.list_tests_for_device(DUT, DB_PATH)
|
all_dut_tests = db.list_tests_for_device(DUT, DB_PATH)
|
||||||
graph.build_graph_once(all_dut_tests)
|
graph.build_and_persist_graph(all_dut_tests, DB_PATH)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"loaded_tests": count,
|
"loaded_tests": count,
|
||||||
@@ -117,6 +167,7 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail="start_date must be YYYY-MM-DD") from exc
|
raise HTTPException(status_code=400, detail="start_date must be YYYY-MM-DD") from exc
|
||||||
|
|
||||||
|
reset_scheduler_state()
|
||||||
stored_tests = db.list_schedulable_tests(DB_PATH, rule=request.rule)
|
stored_tests = db.list_schedulable_tests(DB_PATH, rule=request.rule)
|
||||||
if not stored_tests:
|
if not stored_tests:
|
||||||
version = db.create_schedule_version([], DB_PATH)
|
version = db.create_schedule_version([], DB_PATH)
|
||||||
@@ -144,6 +195,10 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
|
|||||||
for t in stored_tests
|
for t in stored_tests
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# DB-backed graph retrieval ensures compile works after restart without manual save/load.
|
||||||
|
all_dut_tests = db.list_tests_for_device(DUT, DB_PATH)
|
||||||
|
graph.get_graph(DB_PATH, all_dut_tests)
|
||||||
|
|
||||||
holiday_dates = db.list_holidays(DB_PATH)
|
holiday_dates = db.list_holidays(DB_PATH)
|
||||||
try:
|
try:
|
||||||
entries, completion_date = compile_schedule(
|
entries, completion_date = compile_schedule(
|
||||||
@@ -161,7 +216,7 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
|
|||||||
[(e.test_id, e.device, e.scheduled_date, e.shift_index, e.sequence_in_shift) for e in entries],
|
[(e.test_id, e.device, e.scheduled_date, e.shift_index, e.sequence_in_shift) for e in entries],
|
||||||
DB_PATH,
|
DB_PATH,
|
||||||
)
|
)
|
||||||
|
print(f"Schedule version {version} created with {len(entries)} entries, completion date: {completion_date}")
|
||||||
return {
|
return {
|
||||||
"schedule_version": version,
|
"schedule_version": version,
|
||||||
"scheduled_tests": len(entries),
|
"scheduled_tests": len(entries),
|
||||||
@@ -169,6 +224,14 @@ def compile_schedule_endpoint(request: CompileScheduleRequest) -> dict[str, Any]
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/tests/rerun")
|
||||||
|
def get_rerun_tests() -> dict[str, Any]:
|
||||||
|
db.mark_overdue_as_rerun(DB_PATH)
|
||||||
|
tests = db.get_rerun_tests(DB_PATH)
|
||||||
|
total_minutes = sum(t["estimated_minutes"] for t in tests)
|
||||||
|
return {"tests": tests, "total_estimated_minutes": total_minutes}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/holidays")
|
@app.post("/api/holidays")
|
||||||
def save_holidays(request: SaveHolidaysRequest) -> dict[str, Any]:
|
def save_holidays(request: SaveHolidaysRequest) -> dict[str, Any]:
|
||||||
dates = [d.strip() for d in request.dates if d.strip()]
|
dates = [d.strip() for d in request.dates if d.strip()]
|
||||||
|
|||||||
+158
-1
@@ -87,7 +87,7 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
|
|||||||
config_json TEXT,
|
config_json TEXT,
|
||||||
throttled INTEGER NOT NULL DEFAULT 0,
|
throttled INTEGER NOT NULL DEFAULT 0,
|
||||||
estimated_minutes INTEGER NOT NULL,
|
estimated_minutes INTEGER NOT NULL,
|
||||||
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed', 'invalid')),
|
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'rerun')),
|
||||||
excluded INTEGER NOT NULL DEFAULT 0,
|
excluded INTEGER NOT NULL DEFAULT 0,
|
||||||
raw_payload TEXT,
|
raw_payload TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -123,6 +123,13 @@ def init_db(db_path: str | Path = DB_PATH) -> None:
|
|||||||
minutes INTEGER NOT NULL
|
minutes INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS graph_cache (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
test_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS rerun_events (
|
CREATE TABLE IF NOT EXISTS rerun_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
detected_date TEXT NOT NULL,
|
detected_date TEXT NOT NULL,
|
||||||
@@ -246,6 +253,52 @@ def read_settings(db_path: str | Path = DB_PATH) -> dict[str, Any]:
|
|||||||
return {row["key"]: json.loads(row["value_json"]) for row in rows}
|
return {row["key"]: json.loads(row["value_json"]) for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def save_graph_cache(
|
||||||
|
name: str,
|
||||||
|
payload: dict[str, list[str]],
|
||||||
|
test_count: int,
|
||||||
|
db_path: str | Path = DB_PATH,
|
||||||
|
) -> None:
|
||||||
|
with get_connection(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO graph_cache(name, payload_json, test_count, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
payload_json = excluded.payload_json,
|
||||||
|
test_count = excluded.test_count,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(name, json.dumps(payload), int(test_count)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_graph_cache(name: str, db_path: str | Path = DB_PATH) -> dict[str, Any] | None:
|
||||||
|
with get_connection(db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT payload_json, test_count, updated_at
|
||||||
|
FROM graph_cache
|
||||||
|
WHERE name = ?
|
||||||
|
""",
|
||||||
|
(name,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"payload": json.loads(row["payload_json"] or "{}"),
|
||||||
|
"test_count": int(row["test_count"]),
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_graph_cache(name: str, db_path: str | Path = DB_PATH) -> None:
|
||||||
|
with get_connection(db_path) as conn:
|
||||||
|
conn.execute("DELETE FROM graph_cache WHERE name = ?", (name,))
|
||||||
|
|
||||||
|
|
||||||
def _parse_rule_tokens(rule: str | None) -> list[str]:
|
def _parse_rule_tokens(rule: str | None) -> list[str]:
|
||||||
if not rule:
|
if not rule:
|
||||||
return []
|
return []
|
||||||
@@ -543,6 +596,110 @@ 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:
|
||||||
|
"""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 (1am–10am) 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
|
||||||
|
now = _datetime.now()
|
||||||
|
today = now.date().isoformat()
|
||||||
|
shift1_ended = now.hour >= 10 # shift 1 ends ~10am
|
||||||
|
|
||||||
|
with get_connection(db_path) as conn:
|
||||||
|
version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone()
|
||||||
|
latest = version_row["latest"]
|
||||||
|
if latest is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if shift1_ended:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT s.test_id, s.device
|
||||||
|
FROM schedules s
|
||||||
|
JOIN tests t ON t.test_id = s.test_id AND t.device = s.device
|
||||||
|
WHERE s.schedule_version = ?
|
||||||
|
AND t.status = 'pending'
|
||||||
|
AND (
|
||||||
|
s.scheduled_date < ?
|
||||||
|
OR (s.scheduled_date = ? AND s.shift_index = 1)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(latest, today, today),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT s.test_id, s.device
|
||||||
|
FROM schedules s
|
||||||
|
JOIN tests t ON t.test_id = s.test_id AND t.device = s.device
|
||||||
|
WHERE s.schedule_version = ?
|
||||||
|
AND s.scheduled_date < ?
|
||||||
|
AND t.status = 'pending'
|
||||||
|
""",
|
||||||
|
(latest, today),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
UPDATE tests
|
||||||
|
SET status = 'rerun', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE test_id = ? AND device = ? AND status = 'pending'
|
||||||
|
""",
|
||||||
|
[(r["test_id"], r["device"]) for r in rows],
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def get_rerun_tests(db_path: str | Path = DB_PATH) -> list[dict]:
|
||||||
|
"""Return tests scheduled before shift 2 today that are not yet completed.
|
||||||
|
|
||||||
|
This checks the latest schedule version and returns tests from:
|
||||||
|
- Yesterday's shift 3 (5pm-1am)
|
||||||
|
- Today's shift 1 (1am-10am)
|
||||||
|
|
||||||
|
Once tests are rescheduled to shift 2 or later today, they no longer appear.
|
||||||
|
"""
|
||||||
|
from datetime import date as _date
|
||||||
|
today = _date.today().isoformat()
|
||||||
|
with get_connection(db_path) as conn:
|
||||||
|
version_row = conn.execute("SELECT MAX(schedule_version) AS latest FROM schedules").fetchone()
|
||||||
|
latest = version_row["latest"]
|
||||||
|
if latest is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT t.test_id, t.device, t.test_type, t.estimated_minutes
|
||||||
|
FROM tests t
|
||||||
|
JOIN schedules s ON s.test_id = t.test_id AND s.device = t.device
|
||||||
|
WHERE s.schedule_version = ?
|
||||||
|
AND t.status != 'completed'
|
||||||
|
AND (
|
||||||
|
(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
|
||||||
|
""",
|
||||||
|
(latest, today, today),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"test_id": r["test_id"],
|
||||||
|
"device": r["device"],
|
||||||
|
"test_type": r["test_type"],
|
||||||
|
"estimated_minutes": r["estimated_minutes"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def upsert_holidays(dates: list[str], db_path: str | Path = DB_PATH) -> None:
|
def upsert_holidays(dates: list[str], db_path: str | Path = DB_PATH) -> None:
|
||||||
"""Replace all holidays with the provided list of YYYY-MM-DD date strings."""
|
"""Replace all holidays with the provided list of YYYY-MM-DD date strings."""
|
||||||
with get_connection(db_path) as conn:
|
with get_connection(db_path) as conn:
|
||||||
|
|||||||
+58
-7
@@ -1,12 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
# Immutable-in-practice graph state for the loaded DUT test set.
|
# Immutable-in-practice graph state for the loaded DUT test set.
|
||||||
_TESTS_BY_ID: dict[str, Any] = {}
|
_TESTS_BY_ID: dict[str, Any] = {}
|
||||||
_GRAPH: dict[str, set[str]] = {}
|
_GRAPH: dict[str, set[str]] = {}
|
||||||
|
DUT = os.getenv("DUT", "CGW453").strip()
|
||||||
|
APP_ROOT = Path(__file__).resolve().parent
|
||||||
|
DB_PATH = APP_ROOT / "scheduler.db"
|
||||||
|
GRAPH_CACHE_NAME = "dut_compatibility"
|
||||||
|
|
||||||
|
|
||||||
def reset_graph_state() -> None:
|
def reset_graph_state() -> None:
|
||||||
@@ -44,7 +49,58 @@ def build_graph_once(tests: list[Any]) -> dict[str, set[str]]:
|
|||||||
return _GRAPH
|
return _GRAPH
|
||||||
|
|
||||||
|
|
||||||
def get_graph() -> dict[str, set[str]]:
|
def serialize_graph(graph: dict[str, set[str]]) -> dict[str, list[str]]:
|
||||||
|
return {str(test_id): sorted(str(neighbor) for neighbor in neighbors) for test_id, neighbors in graph.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def deserialize_graph(data: dict[str, list[str]]) -> dict[str, set[str]]:
|
||||||
|
deserialized: dict[str, set[str]] = {}
|
||||||
|
for test_id, neighbors in (data or {}).items():
|
||||||
|
deserialized[str(test_id)] = {str(neighbor) for neighbor in (neighbors or [])}
|
||||||
|
return deserialized
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_graph(db_path: str | Path = DB_PATH) -> None:
|
||||||
|
import db as db_module
|
||||||
|
db_module.save_graph_cache(
|
||||||
|
name=GRAPH_CACHE_NAME,
|
||||||
|
payload=serialize_graph(_GRAPH),
|
||||||
|
test_count=len(_GRAPH),
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_graph_from_db(db_path: str | Path = DB_PATH) -> bool:
|
||||||
|
global _GRAPH
|
||||||
|
import db as db_module
|
||||||
|
cached = db_module.load_graph_cache(GRAPH_CACHE_NAME, db_path)
|
||||||
|
if cached is None:
|
||||||
|
return False
|
||||||
|
_GRAPH = deserialize_graph(cached.get("payload") or {})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_and_persist_graph(tests: list[Any], db_path: str | Path = DB_PATH) -> dict[str, set[str]]:
|
||||||
|
graph = build_graph_once(tests)
|
||||||
|
_persist_graph(db_path)
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def get_graph(
|
||||||
|
db_path: str | Path = DB_PATH,
|
||||||
|
dut_tests: list[Any] | None = None,
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
if _GRAPH:
|
||||||
|
return _GRAPH
|
||||||
|
|
||||||
|
if _load_graph_from_db(db_path):
|
||||||
|
return _GRAPH
|
||||||
|
|
||||||
|
if dut_tests is None:
|
||||||
|
import db as db_module
|
||||||
|
dut_tests = db_module.list_tests_for_device(DUT, db_path)
|
||||||
|
|
||||||
|
build_and_persist_graph(dut_tests or [], db_path)
|
||||||
return _GRAPH
|
return _GRAPH
|
||||||
|
|
||||||
|
|
||||||
@@ -60,10 +116,6 @@ def _build_graph(tests: dict[str, Any]) -> dict[str, set[str]]:
|
|||||||
if _compatible(a, b):
|
if _compatible(a, b):
|
||||||
graph[a_id].add(b_id)
|
graph[a_id].add(b_id)
|
||||||
graph[b_id].add(a_id)
|
graph[b_id].add(a_id)
|
||||||
# Print out graph for debugging
|
|
||||||
out = Path('output1.txt')
|
|
||||||
with out.open('w', encoding='utf-8') as f:
|
|
||||||
f.write(json.dumps({k: list(v) for k, v in graph.items()}, indent=2))
|
|
||||||
return graph
|
return graph
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +145,6 @@ def _same_testpoint_to_station(
|
|||||||
stations_a = {s for s, t in station_map_a.items() if t == testpoint}
|
stations_a = {s for s, t in station_map_a.items() if t == testpoint}
|
||||||
stations_b = {s for s, t in station_map_b.items() if t == testpoint}
|
stations_b = {s for s, t in station_map_b.items() if t == testpoint}
|
||||||
if stations_a != stations_b:
|
if stations_a != stations_b:
|
||||||
print(f"Tests {test_a.test_id} and {test_b.test_id} have conflicting stations for testpoint. {station_map_a} vs {station_map_b}")
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
+185
-16
@@ -1,7 +1,14 @@
|
|||||||
import csv
|
import csv
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import smbclient # type: ignore[import-not-found]
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
smbclient = None
|
||||||
|
|
||||||
from db import DEVICE_DUT, DEVICE_REF, TestRecord
|
from db import DEVICE_DUT, DEVICE_REF, TestRecord
|
||||||
|
|
||||||
@@ -81,6 +88,126 @@ class CsvValidationError(ValueError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_SMB_SESSIONS: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_input_path(path_value: str | Path) -> str:
|
||||||
|
path = str(path_value).strip()
|
||||||
|
if not path:
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Accept //server/share style and normalize to UNC for smbclient.
|
||||||
|
if path.startswith("//"):
|
||||||
|
return "\\\\" + path.lstrip("/").replace("/", "\\")
|
||||||
|
|
||||||
|
# Accept /<ipv4>/<share>/... and normalize to UNC for Linux-hosted inputs.
|
||||||
|
if re.match(r"^/\d{1,3}(?:\.\d{1,3}){3}/[^/]+", path):
|
||||||
|
return "\\\\" + path.lstrip("/").replace("/", "\\")
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _is_unc_path(path: str) -> bool:
|
||||||
|
return path.startswith("\\\\")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_unc_server(path: str) -> str | None:
|
||||||
|
if not _is_unc_path(path):
|
||||||
|
return None
|
||||||
|
rest = path[2:]
|
||||||
|
return rest.split("\\", 1)[0] if rest else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_smb_credentials(smb_credentials: dict[str, Any] | None) -> tuple[str, str, str]:
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
domain = ""
|
||||||
|
|
||||||
|
if smb_credentials:
|
||||||
|
username = str(smb_credentials.get("username", "")).strip()
|
||||||
|
password = str(smb_credentials.get("password", ""))
|
||||||
|
domain = str(smb_credentials.get("domain", "")).strip()
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
username = os.getenv("SMB_USERNAME", "").strip()
|
||||||
|
if not password:
|
||||||
|
password = os.getenv("SMB_PASSWORD", "")
|
||||||
|
if not domain:
|
||||||
|
domain = os.getenv("SMB_DOMAIN", "").strip()
|
||||||
|
|
||||||
|
if username and domain and "\\" not in username and "@" not in username:
|
||||||
|
username = f"{domain}\\{username}"
|
||||||
|
|
||||||
|
return username, password, domain
|
||||||
|
|
||||||
|
|
||||||
|
def _register_smb_session_if_needed(path: str, smb_credentials: dict[str, Any] | None) -> None:
|
||||||
|
if not _is_unc_path(path):
|
||||||
|
return
|
||||||
|
|
||||||
|
if smbclient is None:
|
||||||
|
raise ModuleNotFoundError("smbclient is required to read CSV files from UNC paths")
|
||||||
|
|
||||||
|
server = _extract_unc_server(path)
|
||||||
|
if not server or server in _SMB_SESSIONS:
|
||||||
|
return
|
||||||
|
|
||||||
|
username, password, _ = _normalize_smb_credentials(smb_credentials)
|
||||||
|
if username:
|
||||||
|
smbclient.register_session(server, username=username, password=password)
|
||||||
|
else:
|
||||||
|
smbclient.register_session(server)
|
||||||
|
|
||||||
|
_SMB_SESSIONS.add(server)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_exists(path: str, smb_credentials: dict[str, Any] | None) -> bool:
|
||||||
|
if _is_unc_path(path):
|
||||||
|
_register_smb_session_if_needed(path, smb_credentials)
|
||||||
|
try:
|
||||||
|
smbclient.stat(path)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return Path(path).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dir(path: str, smb_credentials: dict[str, Any] | None) -> bool:
|
||||||
|
if _is_unc_path(path):
|
||||||
|
_register_smb_session_if_needed(path, smb_credentials)
|
||||||
|
try:
|
||||||
|
entry_iter = smbclient.scandir(path)
|
||||||
|
for _ in entry_iter:
|
||||||
|
break
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return Path(path).is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def _path_name(path: str) -> str:
|
||||||
|
trimmed = path.rstrip("\\/")
|
||||||
|
if not trimmed:
|
||||||
|
return path
|
||||||
|
parts = re.split(r"[\\/]", trimmed)
|
||||||
|
return parts[-1] if parts else trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_paths_from_dir(path: str, smb_credentials: dict[str, Any] | None) -> list[str]:
|
||||||
|
if _is_unc_path(path):
|
||||||
|
_register_smb_session_if_needed(path, smb_credentials)
|
||||||
|
entries = []
|
||||||
|
for entry in smbclient.scandir(path):
|
||||||
|
name = getattr(entry, "name", "")
|
||||||
|
if name and name.lower().endswith(".csv") and entry.is_file():
|
||||||
|
entries.append(path.rstrip("\\/") + "\\" + name)
|
||||||
|
return sorted(entries)
|
||||||
|
|
||||||
|
return [str(p) for p in sorted(Path(path).glob("*.csv"))]
|
||||||
|
|
||||||
|
|
||||||
def _columns_missing(normalized_fieldnames: set[str], required_columns: list[str]) -> list[str]:
|
def _columns_missing(normalized_fieldnames: set[str], required_columns: list[str]) -> list[str]:
|
||||||
return [
|
return [
|
||||||
column
|
column
|
||||||
@@ -104,20 +231,25 @@ def _detect_csv_format(normalized_fieldnames: set[str]) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_target_csv(csv_path: str | Path | list[str | Path] | tuple[str | Path, ...]) -> ParseResult:
|
def parse_target_csv(
|
||||||
paths = _resolve_csv_paths(csv_path)
|
csv_path: str | Path | list[str | Path] | tuple[str | Path, ...],
|
||||||
|
smb_credentials: dict[str, Any] | None = None,
|
||||||
|
runtime_overrides: dict[str, Any] | None = None,
|
||||||
|
) -> ParseResult:
|
||||||
|
paths = _resolve_csv_paths(csv_path, smb_credentials=smb_credentials)
|
||||||
|
runtime_defaults = _resolve_runtime_defaults(runtime_overrides)
|
||||||
all_tests: list[TestRecord] = []
|
all_tests: list[TestRecord] = []
|
||||||
all_warnings: list[str] = []
|
all_warnings: list[str] = []
|
||||||
seen_test_keys: set[tuple[str, str]] = set()
|
seen_test_keys: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
parsed = _parse_single_csv(path)
|
parsed = _parse_single_csv(path, smb_credentials=smb_credentials, runtime_defaults=runtime_defaults)
|
||||||
all_warnings.extend(parsed.warnings)
|
all_warnings.extend(parsed.warnings)
|
||||||
for record in parsed.tests:
|
for record in parsed.tests:
|
||||||
key = (record.test_id, record.device)
|
key = (record.test_id, record.device)
|
||||||
if key in seen_test_keys:
|
if key in seen_test_keys:
|
||||||
all_warnings.append(
|
all_warnings.append(
|
||||||
f"File {path.name}: duplicate test/device '{record.test_id}/{record.device}', record skipped."
|
f"File {_path_name(path)}: duplicate test/device '{record.test_id}/{record.device}', record skipped."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
seen_test_keys.add(key)
|
seen_test_keys.add(key)
|
||||||
@@ -126,17 +258,43 @@ def parse_target_csv(csv_path: str | Path | list[str | Path] | tuple[str | Path,
|
|||||||
return ParseResult(tests=all_tests, warnings=all_warnings)
|
return ParseResult(tests=all_tests, warnings=all_warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_runtime_defaults(runtime_overrides: dict[str, Any] | None) -> dict[str, int]:
|
||||||
|
defaults = dict(RUNTIME_DEFAULTS)
|
||||||
|
if not runtime_overrides:
|
||||||
|
return defaults
|
||||||
|
|
||||||
def _resolve_csv_paths(csv_path: str | Path | list[str | Path] | tuple[str | Path, ...]) -> list[Path]:
|
for test_type in ("P2P", "COE", "P3P"):
|
||||||
|
raw_value = runtime_overrides.get(test_type)
|
||||||
|
if raw_value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(raw_value, str):
|
||||||
|
raw_value = raw_value.strip()
|
||||||
|
if not raw_value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
minutes = int(raw_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if minutes > 0:
|
||||||
|
defaults[test_type] = minutes
|
||||||
|
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_csv_paths(
|
||||||
|
csv_path: str | Path | list[str | Path] | tuple[str | Path, ...],
|
||||||
|
smb_credentials: dict[str, Any] | None,
|
||||||
|
) -> list[str]:
|
||||||
if isinstance(csv_path, (str, Path)):
|
if isinstance(csv_path, (str, Path)):
|
||||||
paths = [Path(csv_path)]
|
paths = [_normalize_input_path(csv_path)]
|
||||||
else:
|
else:
|
||||||
paths = [Path(item) for item in csv_path]
|
paths = [_normalize_input_path(item) for item in csv_path]
|
||||||
|
|
||||||
resolved_paths: list[Path] = []
|
resolved_paths: list[str] = []
|
||||||
for path in paths:
|
for path in paths:
|
||||||
if path.is_dir():
|
if _is_dir(path, smb_credentials=smb_credentials):
|
||||||
resolved_paths.extend(sorted(path.glob("*.csv")))
|
resolved_paths.extend(_csv_paths_from_dir(path, smb_credentials=smb_credentials))
|
||||||
else:
|
else:
|
||||||
resolved_paths.append(path)
|
resolved_paths.append(path)
|
||||||
|
|
||||||
@@ -144,15 +302,26 @@ def _resolve_csv_paths(csv_path: str | Path | list[str | Path] | tuple[str | Pat
|
|||||||
raise FileNotFoundError("No CSV files found to parse.")
|
raise FileNotFoundError("No CSV files found to parse.")
|
||||||
|
|
||||||
for path in resolved_paths:
|
for path in resolved_paths:
|
||||||
if not path.exists():
|
if not _path_exists(path, smb_credentials=smb_credentials):
|
||||||
raise FileNotFoundError(f"CSV file not found: {path}")
|
raise FileNotFoundError(f"CSV file not found: {path}")
|
||||||
|
|
||||||
return resolved_paths
|
return resolved_paths
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_single_csv(path: Path) -> ParseResult:
|
def _open_csv_handle(path: str, smb_credentials: dict[str, Any] | None):
|
||||||
with path.open("r", encoding="utf-8-sig", newline="") as handle:
|
if _is_unc_path(path):
|
||||||
|
_register_smb_session_if_needed(path, smb_credentials)
|
||||||
|
return smbclient.open_file(path, mode="r", encoding="utf-8-sig", newline="")
|
||||||
|
return Path(path).open("r", encoding="utf-8-sig", newline="")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_single_csv(
|
||||||
|
path: str,
|
||||||
|
smb_credentials: dict[str, Any] | None,
|
||||||
|
runtime_defaults: dict[str, int],
|
||||||
|
) -> ParseResult:
|
||||||
|
with _open_csv_handle(path, smb_credentials=smb_credentials) as handle:
|
||||||
reader = csv.DictReader(handle)
|
reader = csv.DictReader(handle)
|
||||||
if not reader.fieldnames:
|
if not reader.fieldnames:
|
||||||
raise CsvValidationError("CSV is missing a header row.")
|
raise CsvValidationError("CSV is missing a header row.")
|
||||||
@@ -169,11 +338,11 @@ def _parse_single_csv(path: Path) -> ParseResult:
|
|||||||
for row_num, row in enumerate(reader, start=2):
|
for row_num, row in enumerate(reader, start=2):
|
||||||
test_id = (row.get("TC ID") or "").strip()
|
test_id = (row.get("TC ID") or "").strip()
|
||||||
if not test_id:
|
if not test_id:
|
||||||
warnings.append(f"File {path.name}, row {row_num}: missing TC ID, row skipped.")
|
warnings.append(f"File {_path_name(path)}, row {row_num}: missing TC ID, row skipped.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if test_id in seen_test_ids:
|
if test_id in seen_test_ids:
|
||||||
warnings.append(f"File {path.name}, row {row_num}: duplicate TC ID '{test_id}', row skipped.")
|
warnings.append(f"File {_path_name(path)}, row {row_num}: duplicate TC ID '{test_id}', row skipped.")
|
||||||
continue
|
continue
|
||||||
seen_test_ids.add(test_id)
|
seen_test_ids.add(test_id)
|
||||||
|
|
||||||
@@ -184,7 +353,7 @@ def _parse_single_csv(path: Path) -> ParseResult:
|
|||||||
has_coe_pair = _normalize_yes_no(_row_get(row, "COE Pair")) if csv_format == "p2p_coe" else False
|
has_coe_pair = _normalize_yes_no(_row_get(row, "COE Pair")) if csv_format == "p2p_coe" else False
|
||||||
config = _build_config(row, csv_format)
|
config = _build_config(row, csv_format)
|
||||||
signature = _victim_band_signature(row) if csv_format == "p2p_coe" else None
|
signature = _victim_band_signature(row) if csv_format == "p2p_coe" else None
|
||||||
estimated_minutes = RUNTIME_DEFAULTS.get(test_type)
|
estimated_minutes = runtime_defaults.get(test_type, RUNTIME_DEFAULTS[test_type])
|
||||||
victim_band_source = "Victim Band" if csv_format == "p2p_coe" else "Band"
|
victim_band_source = "Victim Band" if csv_format == "p2p_coe" else "Band"
|
||||||
victim_band = _normalize_victim_band(_row_get(row, victim_band_source))
|
victim_band = _normalize_victim_band(_row_get(row, victim_band_source))
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn==0.35.0
|
|||||||
pydantic==2.11.7
|
pydantic==2.11.7
|
||||||
watchdog==6.0.0
|
watchdog==6.0.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
smbprotocol==1.15.0
|
||||||
|
|||||||
+35
-14
@@ -3,11 +3,11 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import smbclient
|
import smbclient # type: ignore[import-not-found]
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
smbclient = None
|
smbclient = None
|
||||||
|
|
||||||
from db import DEVICE_DUT, DEVICE_REF, mark_tests_completed
|
from db import DEVICE_DUT, DEVICE_REF, mark_tests_completed, mark_overdue_as_rerun
|
||||||
|
|
||||||
_SMB_SESSIONS = set()
|
_SMB_SESSIONS = set()
|
||||||
_SCAN_STATE_LOCK = threading.Lock()
|
_SCAN_STATE_LOCK = threading.Lock()
|
||||||
@@ -15,6 +15,29 @@ _ACTIVE_SCAN_COUNT = 0
|
|||||||
_RESULT_TEST_ID_PATTERN = re.compile(r"(?:COE|P2P|P3P)(?:RX|TX)?[A-Z]{2}\d{3}", re.IGNORECASE)
|
_RESULT_TEST_ID_PATTERN = re.compile(r"(?:COE|P2P|P3P)(?:RX|TX)?[A-Z]{2}\d{3}", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_smb_credentials(smb_credentials=None):
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
domain = ""
|
||||||
|
|
||||||
|
if smb_credentials:
|
||||||
|
username = str(smb_credentials.get("username", "")).strip()
|
||||||
|
password = str(smb_credentials.get("password", ""))
|
||||||
|
domain = str(smb_credentials.get("domain", "")).strip()
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
username = os.getenv("SMB_USERNAME", "").strip()
|
||||||
|
if not password:
|
||||||
|
password = os.getenv("SMB_PASSWORD", "")
|
||||||
|
if not domain:
|
||||||
|
domain = os.getenv("SMB_DOMAIN", "").strip()
|
||||||
|
|
||||||
|
if username and domain and "\\" not in username and "@" not in username:
|
||||||
|
username = f"{domain}\\{username}"
|
||||||
|
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
|
||||||
def _extract_test_id_from_result_dir_name(dir_name):
|
def _extract_test_id_from_result_dir_name(dir_name):
|
||||||
if not dir_name:
|
if not dir_name:
|
||||||
return None
|
return None
|
||||||
@@ -171,7 +194,7 @@ def _extract_unc_server(path):
|
|||||||
return rest.split("\\", 1)[0] if rest else None
|
return rest.split("\\", 1)[0] if rest else None
|
||||||
|
|
||||||
|
|
||||||
def _register_smb_session_if_needed(path):
|
def _register_smb_session_if_needed(path, smb_credentials=None):
|
||||||
if not _is_unc_path(path):
|
if not _is_unc_path(path):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -182,12 +205,7 @@ def _register_smb_session_if_needed(path):
|
|||||||
if not server or server in _SMB_SESSIONS:
|
if not server or server in _SMB_SESSIONS:
|
||||||
return
|
return
|
||||||
|
|
||||||
username = os.getenv("SMB_USERNAME", "").strip()
|
username, password = _normalize_smb_credentials(smb_credentials)
|
||||||
password = os.getenv("SMB_PASSWORD", "")
|
|
||||||
domain = os.getenv("SMB_DOMAIN", "").strip()
|
|
||||||
|
|
||||||
if username and domain and "\\" not in username and "@" not in username:
|
|
||||||
username = f"{domain}\\{username}"
|
|
||||||
|
|
||||||
if username:
|
if username:
|
||||||
smbclient.register_session(server, username=username, password=password)
|
smbclient.register_session(server, username=username, password=password)
|
||||||
@@ -197,17 +215,17 @@ def _register_smb_session_if_needed(path):
|
|||||||
_SMB_SESSIONS.add(server)
|
_SMB_SESSIONS.add(server)
|
||||||
|
|
||||||
|
|
||||||
def _iter_dir_entries(path):
|
def _iter_dir_entries(path, smb_credentials=None):
|
||||||
path = _normalize_input_path(path)
|
path = _normalize_input_path(path)
|
||||||
if _is_unc_path(path):
|
if _is_unc_path(path):
|
||||||
if smbclient is None:
|
if smbclient is None:
|
||||||
raise ModuleNotFoundError("smbclient is required to scan UNC result paths")
|
raise ModuleNotFoundError("smbclient is required to scan UNC result paths")
|
||||||
_register_smb_session_if_needed(path)
|
_register_smb_session_if_needed(path, smb_credentials=smb_credentials)
|
||||||
return list(smbclient.scandir(path))
|
return list(smbclient.scandir(path))
|
||||||
return list(os.scandir(path))
|
return list(os.scandir(path))
|
||||||
|
|
||||||
|
|
||||||
def scan_results(results_dir_dut, results_dir_ref):
|
def scan_results(results_dir_dut, results_dir_ref, smb_credentials=None):
|
||||||
results_dir_dut = _normalize_input_path(results_dir_dut)
|
results_dir_dut = _normalize_input_path(results_dir_dut)
|
||||||
results_dir_ref = _normalize_input_path(results_dir_ref)
|
results_dir_ref = _normalize_input_path(results_dir_ref)
|
||||||
|
|
||||||
@@ -217,7 +235,7 @@ def scan_results(results_dir_dut, results_dir_ref):
|
|||||||
try:
|
try:
|
||||||
dut_entries = [
|
dut_entries = [
|
||||||
entry.name
|
entry.name
|
||||||
for entry in _iter_dir_entries(results_dir_dut)
|
for entry in _iter_dir_entries(results_dir_dut, smb_credentials=smb_credentials)
|
||||||
if entry.is_dir()
|
if entry.is_dir()
|
||||||
]
|
]
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -229,7 +247,7 @@ def scan_results(results_dir_dut, results_dir_ref):
|
|||||||
try:
|
try:
|
||||||
ref_entries = [
|
ref_entries = [
|
||||||
entry.name
|
entry.name
|
||||||
for entry in _iter_dir_entries(results_dir_ref)
|
for entry in _iter_dir_entries(results_dir_ref, smb_credentials=smb_credentials)
|
||||||
if entry.is_dir()
|
if entry.is_dir()
|
||||||
]
|
]
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -259,4 +277,7 @@ def scan_results(results_dir_dut, results_dir_ref):
|
|||||||
print(f"[scanner] skipped {len(unmatched_entries)} result dir(s) with no recognizable test id")
|
print(f"[scanner] skipped {len(unmatched_entries)} result dir(s) with no recognizable test id")
|
||||||
|
|
||||||
mark_tests_completed(completed_batch)
|
mark_tests_completed(completed_batch)
|
||||||
|
newly_rerun = mark_overdue_as_rerun()
|
||||||
|
if newly_rerun:
|
||||||
|
print(f"[scanner] {newly_rerun} test(s) marked as rerun-required (scheduled but not completed)")
|
||||||
|
|
||||||
|
|||||||
@@ -125,8 +125,6 @@ def compile_schedule(
|
|||||||
"""
|
"""
|
||||||
if not tests:
|
if not tests:
|
||||||
return [], None
|
return [], None
|
||||||
if not graph.is_graph_built():
|
|
||||||
raise RuntimeError("Graph not initialized. Load tests first to build DUT compatibility graph.")
|
|
||||||
|
|
||||||
initialize_scheduler_state(tests)
|
initialize_scheduler_state(tests)
|
||||||
set_user_priorities(top_priority_tests, lowest_priority_tests)
|
set_user_priorities(top_priority_tests, lowest_priority_tests)
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from watchdog.events import FileSystemEvent, FileSystemEventHandler, FileSystemMovedEvent
|
||||||
|
from watchdog.observers.polling import PollingObserver
|
||||||
|
|
||||||
|
from scanner import resolve_runtime_path, scan_results
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("scheduler.watcher")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WatchConfig:
|
||||||
|
dut_dir: str
|
||||||
|
ref_dir: str
|
||||||
|
smb_username: str
|
||||||
|
smb_password: str
|
||||||
|
smb_domain: str
|
||||||
|
|
||||||
|
|
||||||
|
class _ResultDirEventHandler(FileSystemEventHandler):
|
||||||
|
def __init__(self, on_new_directory):
|
||||||
|
super().__init__()
|
||||||
|
self._on_new_directory = on_new_directory
|
||||||
|
|
||||||
|
def on_created(self, event: FileSystemEvent) -> None:
|
||||||
|
if event.is_directory:
|
||||||
|
self._on_new_directory(event.src_path)
|
||||||
|
|
||||||
|
def on_moved(self, event: FileSystemMovedEvent) -> None:
|
||||||
|
if event.is_directory:
|
||||||
|
self._on_new_directory(event.dest_path)
|
||||||
|
|
||||||
|
|
||||||
|
class ResultDirectoryWatcher:
|
||||||
|
"""Watch DUT/REF result directories and trigger scans when new result folders appear."""
|
||||||
|
|
||||||
|
def __init__(self, poll_interval_seconds: float = 2.0, min_scan_interval_seconds: float = 1.0):
|
||||||
|
self._poll_interval_seconds = poll_interval_seconds
|
||||||
|
self._min_scan_interval_seconds = min_scan_interval_seconds
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._scan_lock = threading.Lock()
|
||||||
|
self._observer: PollingObserver | None = None
|
||||||
|
self._current_config: WatchConfig | None = None
|
||||||
|
self._last_scan_monotonic = 0.0
|
||||||
|
|
||||||
|
def configure_from_settings(self, settings: dict[str, Any]) -> None:
|
||||||
|
dut_dir = _normalize_watch_path(settings.get("dutResultDir"))
|
||||||
|
ref_dir = _normalize_watch_path(settings.get("refResultDir"))
|
||||||
|
new_config = WatchConfig(
|
||||||
|
dut_dir=dut_dir,
|
||||||
|
ref_dir=ref_dir,
|
||||||
|
smb_username=str(settings.get("smbUsername") or "").strip(),
|
||||||
|
smb_password=str(settings.get("smbPassword") or ""),
|
||||||
|
smb_domain=str(settings.get("smbDomain") or "").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if new_config == self._current_config and self._observer is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._stop_locked()
|
||||||
|
self._current_config = new_config
|
||||||
|
|
||||||
|
if not dut_dir or not ref_dir:
|
||||||
|
LOGGER.info("result watcher disabled: DUT/REF result directories are not both set")
|
||||||
|
return
|
||||||
|
|
||||||
|
handler = _ResultDirEventHandler(self._on_new_directory)
|
||||||
|
observer = PollingObserver(timeout=self._poll_interval_seconds)
|
||||||
|
|
||||||
|
scheduled_count = 0
|
||||||
|
for result_dir, label in ((dut_dir, "DUT"), (ref_dir, "REF")):
|
||||||
|
if not os.path.isdir(result_dir):
|
||||||
|
LOGGER.warning("%s result directory does not exist yet, skipping watch: %s", label, result_dir)
|
||||||
|
continue
|
||||||
|
observer.schedule(handler, result_dir, recursive=False)
|
||||||
|
scheduled_count += 1
|
||||||
|
|
||||||
|
if scheduled_count == 0:
|
||||||
|
LOGGER.warning("result watcher not started: no valid result directories to watch")
|
||||||
|
return
|
||||||
|
|
||||||
|
observer.start()
|
||||||
|
self._observer = observer
|
||||||
|
LOGGER.info("result watcher started for DUT=%s REF=%s", dut_dir, ref_dir)
|
||||||
|
|
||||||
|
self.scan_now(reason="startup")
|
||||||
|
|
||||||
|
def _on_new_directory(self, directory_path: str) -> None:
|
||||||
|
LOGGER.info("new result directory detected: %s", directory_path)
|
||||||
|
self.scan_now(reason="directory-created")
|
||||||
|
|
||||||
|
def scan_now(self, reason: str = "manual") -> None:
|
||||||
|
with self._lock:
|
||||||
|
config = self._current_config
|
||||||
|
if config is None or not config.dut_dir or not config.ref_dir:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_scan_monotonic < self._min_scan_interval_seconds:
|
||||||
|
return
|
||||||
|
self._last_scan_monotonic = now
|
||||||
|
|
||||||
|
if not self._scan_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _run_scan() -> None:
|
||||||
|
credentials = {
|
||||||
|
"username": config.smb_username,
|
||||||
|
"password": config.smb_password,
|
||||||
|
"domain": config.smb_domain,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
scan_results(config.dut_dir, config.ref_dir, smb_credentials=credentials)
|
||||||
|
LOGGER.info("result scan finished (%s)", reason)
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception("result scan failed (%s)", reason)
|
||||||
|
finally:
|
||||||
|
self._scan_lock.release()
|
||||||
|
|
||||||
|
threading.Thread(target=_run_scan, daemon=True).start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._stop_locked()
|
||||||
|
|
||||||
|
def _stop_locked(self) -> None:
|
||||||
|
observer = self._observer
|
||||||
|
self._observer = None
|
||||||
|
if observer is not None:
|
||||||
|
observer.stop()
|
||||||
|
observer.join(timeout=5)
|
||||||
|
LOGGER.info("result watcher stopped")
|
||||||
|
|
||||||
|
|
||||||
|
_WATCHER = ResultDirectoryWatcher()
|
||||||
|
|
||||||
|
|
||||||
|
def configure_result_watcher(settings: dict[str, Any]) -> None:
|
||||||
|
_WATCHER.configure_from_settings(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_result_watcher() -> None:
|
||||||
|
_WATCHER.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_watch_path(path_value: Any) -> str:
|
||||||
|
if path_value is None:
|
||||||
|
return ""
|
||||||
|
cleaned = str(path_value).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
return str(resolve_runtime_path(cleaned)).strip()
|
||||||
|
|||||||
+90
-16
@@ -17,6 +17,17 @@ function getMondayOfWeek(date) {
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLocalDate(value) {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
|
if (!year || !month || !day) return null
|
||||||
|
|
||||||
|
const parsed = new Date(year, month - 1, day)
|
||||||
|
parsed.setHours(0, 0, 0, 0)
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||||
|
}
|
||||||
|
|
||||||
function toKey(d) { return d.toISOString().slice(0, 10) }
|
function toKey(d) { return d.toISOString().slice(0, 10) }
|
||||||
|
|
||||||
function addDays(date, n) {
|
function addDays(date, n) {
|
||||||
@@ -85,9 +96,22 @@ const DEFAULT_SETTINGS = {
|
|||||||
p3pCsvPath: '',
|
p3pCsvPath: '',
|
||||||
refResultDir: '',
|
refResultDir: '',
|
||||||
dutResultDir: '',
|
dutResultDir: '',
|
||||||
|
smbUsername: '',
|
||||||
|
smbPassword: '',
|
||||||
|
smbDomain: '',
|
||||||
|
p2pRuntimeMinutes: '',
|
||||||
|
coeRuntimeMinutes: '',
|
||||||
|
p3pRuntimeMinutes: '',
|
||||||
testExclusion: '',
|
testExclusion: '',
|
||||||
holidays: '',
|
holidays: '',
|
||||||
startDateOverride: '',
|
}
|
||||||
|
|
||||||
|
function sanitizeSettings(saved = {}) {
|
||||||
|
const { startDateOverride: _ignored, ...rest } = saved
|
||||||
|
return {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -98,6 +122,7 @@ export default function App() {
|
|||||||
const [daytimeEnabled, setDaytimeEnabled] = useState(false)
|
const [daytimeEnabled, setDaytimeEnabled] = useState(false)
|
||||||
const [topPriority, setTopPriority] = useState('')
|
const [topPriority, setTopPriority] = useState('')
|
||||||
const [lowestPriority, setLowestPriority] = useState('')
|
const [lowestPriority, setLowestPriority] = useState('')
|
||||||
|
const [startDateOverride, setStartDateOverride] = useState('')
|
||||||
const [failedTests, setFailedTests] = useState([])
|
const [failedTests, setFailedTests] = useState([])
|
||||||
const [scheduleData, setScheduleData] = useState({})
|
const [scheduleData, setScheduleData] = useState({})
|
||||||
const [completionDate, setCompletionDate] = useState(null)
|
const [completionDate, setCompletionDate] = useState(null)
|
||||||
@@ -116,6 +141,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const fetchRerunTests = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getRerunTests()
|
||||||
|
setFailedTests(data.tests ?? [])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch rerun tests:', e)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// On mount: load settings + holidays + schedule for today's week
|
// On mount: load settings + holidays + schedule for today's week
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -124,9 +158,8 @@ export default function App() {
|
|||||||
api.getSettings(),
|
api.getSettings(),
|
||||||
api.getHolidays(),
|
api.getHolidays(),
|
||||||
])
|
])
|
||||||
setSettings(prev => ({
|
setSettings(() => ({
|
||||||
...prev,
|
...sanitizeSettings(saved),
|
||||||
...saved,
|
|
||||||
holidays: (holidayData.dates ?? []).join(', '),
|
holidays: (holidayData.dates ?? []).join(', '),
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -142,26 +175,43 @@ export default function App() {
|
|||||||
fetchSchedule(weekStart)
|
fetchSchedule(weekStart)
|
||||||
}, [weekStart, fetchSchedule])
|
}, [weekStart, fetchSchedule])
|
||||||
|
|
||||||
|
// Keep calendar statuses fresh when backend watcher marks tests from new result folders.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
fetchSchedule(weekStart)
|
||||||
|
fetchRerunTests()
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [weekStart, fetchSchedule, fetchRerunTests])
|
||||||
|
|
||||||
|
// Load rerun tests on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRerunTests()
|
||||||
|
}, [fetchRerunTests])
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async function handleSaveSettings(newSettings) {
|
async function handleSaveSettings(newSettings) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.saveSettings(newSettings)
|
const sanitizedSettings = sanitizeSettings(newSettings)
|
||||||
|
|
||||||
const holidayDates = (newSettings.holidays ?? '')
|
await api.saveSettings(sanitizedSettings)
|
||||||
|
|
||||||
|
const holidayDates = (sanitizedSettings.holidays ?? '')
|
||||||
.split(',').map(s => s.trim()).filter(Boolean)
|
.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
await api.saveHolidays(holidayDates)
|
await api.saveHolidays(holidayDates)
|
||||||
|
|
||||||
if (newSettings.p2pCoeCsvPath?.trim()) {
|
if (sanitizedSettings.p2pCoeCsvPath?.trim()) {
|
||||||
await api.loadCsv(newSettings.p2pCoeCsvPath.trim())
|
await api.loadCsv(sanitizedSettings.p2pCoeCsvPath.trim())
|
||||||
}
|
}
|
||||||
if (newSettings.p3pCsvPath?.trim()) {
|
if (sanitizedSettings.p3pCsvPath?.trim()) {
|
||||||
await api.loadCsv(newSettings.p3pCsvPath.trim())
|
await api.loadCsv(sanitizedSettings.p3pCsvPath.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
setSettings(newSettings)
|
setSettings(sanitizedSettings)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -173,15 +223,30 @@ export default function App() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
const effectiveStartDate = startDateOverride.trim()
|
||||||
const result = await api.compileSchedule({
|
const result = await api.compileSchedule({
|
||||||
start_date: settings.startDateOverride?.trim() || null,
|
start_date: effectiveStartDate || null,
|
||||||
daytime_testing_today: daytimeEnabled,
|
daytime_testing_today: daytimeEnabled,
|
||||||
top_priority_tests: topPriority.split(',').map(s => s.trim()).filter(Boolean),
|
top_priority_tests: topPriority.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
lowest_priority_tests: lowestPriority.split(',').map(s => s.trim()).filter(Boolean),
|
lowest_priority_tests: lowestPriority.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
rule: settings.testExclusion ?? '',
|
rule: settings.testExclusion ?? '',
|
||||||
})
|
})
|
||||||
setCompletionDate(result.completion_date ?? null)
|
setCompletionDate(result.completion_date ?? null)
|
||||||
|
|
||||||
|
if (effectiveStartDate) {
|
||||||
|
const overrideDate = parseLocalDate(effectiveStartDate)
|
||||||
|
if (overrideDate) {
|
||||||
|
const overrideWeekStart = getMondayOfWeek(overrideDate)
|
||||||
|
setWeekStart(overrideWeekStart)
|
||||||
|
await fetchSchedule(overrideWeekStart)
|
||||||
|
} else {
|
||||||
await fetchSchedule(weekStart)
|
await fetchSchedule(weekStart)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await fetchSchedule(weekStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStartDateOverride('')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -190,8 +255,16 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRerunDecision(rerunDuringDay) {
|
function handleRerunDecision(rerunDuringDay) {
|
||||||
// TODO: POST /api/failed-tests/rerun-decision once backend endpoint exists
|
const rerunIds = failedTests.map(t => t.test_id).join(', ')
|
||||||
console.log('Rerun during day:', rerunDuringDay)
|
setTopPriority(prev => {
|
||||||
|
const existing = prev.split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
const incoming = failedTests.map(t => t.test_id)
|
||||||
|
const merged = [...new Set([...existing, ...incoming])]
|
||||||
|
return merged.join(', ')
|
||||||
|
})
|
||||||
|
if (rerunDuringDay) {
|
||||||
|
setDaytimeEnabled(true)
|
||||||
|
}
|
||||||
setFailedTests([])
|
setFailedTests([])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,8 +282,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FailedBanner
|
<FailedBanner
|
||||||
failedTests={failedTests}
|
rerunTests={failedTests}
|
||||||
estimatedMinutes={0}
|
|
||||||
onDecision={handleRerunDecision}
|
onDecision={handleRerunDecision}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -226,6 +298,8 @@ export default function App() {
|
|||||||
|
|
||||||
<RightPanel
|
<RightPanel
|
||||||
completionDate={completionDate}
|
completionDate={completionDate}
|
||||||
|
startDateOverride={startDateOverride}
|
||||||
|
onStartDateOverrideChange={setStartDateOverride}
|
||||||
daytimeEnabled={daytimeEnabled}
|
daytimeEnabled={daytimeEnabled}
|
||||||
onDaytimeEnabledChange={setDaytimeEnabled}
|
onDaytimeEnabledChange={setDaytimeEnabled}
|
||||||
topPriority={topPriority}
|
topPriority={topPriority}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const api = {
|
|||||||
// Schedule
|
// Schedule
|
||||||
compileSchedule: (opts) => request('POST', '/schedule/compile', opts),
|
compileSchedule: (opts) => request('POST', '/schedule/compile', opts),
|
||||||
getScheduleWeek: (start) => request('GET', `/schedule/week?start=${start}`),
|
getScheduleWeek: (start) => request('GET', `/schedule/week?start=${start}`),
|
||||||
|
getRerunTests: () => request('GET', '/tests/rerun'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform the flat items array from GET /api/schedule/week into the
|
// Transform the flat items array from GET /api/schedule/week into the
|
||||||
|
|||||||
@@ -121,8 +121,7 @@ export default function Calendar({ scheduleData = {}, daytimeDateKey = null, wee
|
|||||||
<div className="flex gap-4 text-xs text-gray-400">
|
<div className="flex gap-4 text-xs text-gray-400">
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-gray-500 inline-block" />Pending</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-gray-500 inline-block" />Pending</span>
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-green-700 inline-block" />Completed</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-green-700 inline-block" />Completed</span>
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-red-700 inline-block" />Failed</span>
|
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-red-700 inline-block" />Rerun Required</span>
|
||||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-sm bg-yellow-700 inline-block" />Invalid</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
export default function FailedBanner({ failedTests, estimatedMinutes, onDecision }) {
|
export default function FailedBanner({ rerunTests, onDecision }) {
|
||||||
if (!failedTests || failedTests.length === 0) return null
|
if (!rerunTests || rerunTests.length === 0) return null
|
||||||
|
|
||||||
const hours = Math.floor(estimatedMinutes / 60)
|
const totalMinutes = rerunTests.reduce((sum, t) => sum + (t.estimated_minutes ?? 0), 0)
|
||||||
const mins = estimatedMinutes % 60
|
const hours = Math.floor(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(', ')
|
||||||
|
|
||||||
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">
|
||||||
@@ -23,13 +25,13 @@ export default function FailedBanner({ failedTests, estimatedMinutes, onDecision
|
|||||||
</svg>
|
</svg>
|
||||||
<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">{failedTests.length} test{failedTests.length !== 1 ? 's' : ''}</span>
|
<span className="font-semibold">{rerunTests.length} test{rerunTests.length !== 1 ? 's' : ''}</span>
|
||||||
{' '}failed last night and require a rerun —{' '}
|
{' '}require a rerun — not completed in last overnight window:{' '}
|
||||||
<span className="font-mono text-red-200">{failedTests.join(', ')}</span>
|
<span className="font-mono text-red-200">{testIds}</span>
|
||||||
</p>
|
</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>
|
||||||
{' '}— Rerun these tests during the day today?
|
{' '}— Schedule rerun during daytime today?
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0">
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ function formatCompletionDate(value) {
|
|||||||
|
|
||||||
export default function RightPanel({
|
export default function RightPanel({
|
||||||
completionDate,
|
completionDate,
|
||||||
|
startDateOverride,
|
||||||
|
onStartDateOverrideChange,
|
||||||
daytimeEnabled,
|
daytimeEnabled,
|
||||||
onDaytimeEnabledChange,
|
onDaytimeEnabledChange,
|
||||||
topPriority,
|
topPriority,
|
||||||
@@ -75,6 +77,18 @@ export default function RightPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold uppercase tracking-wider text-gray-500 mb-1.5">
|
||||||
|
Start Date Override
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDateOverride}
|
||||||
|
onChange={(e) => onStartDateOverrideChange(e.target.value)}
|
||||||
|
className="w-full bg-gray-900 border border-gray-600 rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-blue-500 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Remake schedule */}
|
{/* Remake schedule */}
|
||||||
<button
|
<button
|
||||||
onClick={onRemakeSchedule}
|
onClick={onRemakeSchedule}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
function Field({ label, hint, children }) {
|
function Field({ label, hint, required = false, children }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-semibold text-gray-300 mb-1">
|
<label className="block text-xs font-semibold text-gray-300 mb-1">
|
||||||
{label}
|
{label}
|
||||||
|
{required && <span className="ml-1 text-red-400">*</span>}
|
||||||
{hint && <span className="ml-1.5 text-gray-500 font-normal">{hint}</span>}
|
{hint && <span className="ml-1.5 text-gray-500 font-normal">{hint}</span>}
|
||||||
</label>
|
</label>
|
||||||
{children}
|
{children}
|
||||||
@@ -15,8 +16,6 @@ function Field({ label, hint, 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'
|
||||||
|
|
||||||
const TEXTAREA_CLS = INPUT_CLS + ' resize-none'
|
|
||||||
|
|
||||||
export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
||||||
const [form, setForm] = useState({ ...settings })
|
const [form, setForm] = useState({ ...settings })
|
||||||
|
|
||||||
@@ -69,7 +68,7 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
File Paths
|
File Paths
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Field label="P2P / COE CSV">
|
<Field label="P2P / COE CSV" required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
@@ -78,7 +77,7 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
onChange={(e) => set('p2pCoeCsvPath', e.target.value)}
|
onChange={(e) => set('p2pCoeCsvPath', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="P3P CSV">
|
<Field label="P3P CSV" required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
@@ -87,7 +86,7 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
onChange={(e) => set('p3pCsvPath', e.target.value)}
|
onChange={(e) => set('p3pCsvPath', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="REF Result Directory">
|
<Field label="REF Result Directory" required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
@@ -96,7 +95,7 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
onChange={(e) => set('refResultDir', e.target.value)}
|
onChange={(e) => set('refResultDir', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="DUT Result Directory">
|
<Field label="DUT Result Directory" required>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
@@ -110,6 +109,88 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
|
|
||||||
<hr className="border-gray-700" />
|
<hr className="border-gray-700" />
|
||||||
|
|
||||||
|
{/* Section: Network Shares */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||||
|
Network Shares
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Field label="SMB Username">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="username"
|
||||||
|
value={form.smbUsername ?? ''}
|
||||||
|
onChange={(e) => set('smbUsername', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="SMB Password">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="password"
|
||||||
|
value={form.smbPassword ?? ''}
|
||||||
|
onChange={(e) => set('smbPassword', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="SMB Domain">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="WORKGROUP or DOMAIN"
|
||||||
|
value={form.smbDomain ?? ''}
|
||||||
|
onChange={(e) => set('smbDomain', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-gray-700" />
|
||||||
|
|
||||||
|
{/* Section: Runtime Overrides */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||||
|
Runtime Overrides
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<Field label="P2P Minutes">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="80"
|
||||||
|
value={form.p2pRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('p2pRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="COE Minutes">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="115"
|
||||||
|
value={form.coeRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('coeRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="P3P Minutes">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
className={INPUT_CLS}
|
||||||
|
placeholder="105"
|
||||||
|
value={form.p3pRuntimeMinutes ?? ''}
|
||||||
|
onChange={(e) => set('p3pRuntimeMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-gray-700" />
|
||||||
|
|
||||||
{/* Section: Test Exclusion */}
|
{/* Section: Test Exclusion */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
||||||
@@ -143,23 +224,6 @@ export default function SettingsModal({ isOpen, onClose, settings, onSave }) {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="border-gray-700" />
|
|
||||||
|
|
||||||
{/* Section: Schedule */}
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-gray-500 mb-3">
|
|
||||||
Schedule
|
|
||||||
</p>
|
|
||||||
<Field label="Start Date Override" hint="(YYYY-MM-DD, leave blank for today)">
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className={INPUT_CLS}
|
|
||||||
value={form.startDateOverride ?? ''}
|
|
||||||
onChange={(e) => set('startDateOverride', e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
const STATUS_STYLES = {
|
const STATUS_STYLES = {
|
||||||
pending: 'bg-gray-600/60 text-gray-200 border-gray-500',
|
pending: 'bg-gray-600/60 text-gray-200 border-gray-500',
|
||||||
completed: 'bg-green-800/60 text-green-200 border-green-600',
|
completed: 'bg-green-800/60 text-green-200 border-green-600',
|
||||||
failed: 'bg-red-800/60 text-red-200 border-red-600',
|
rerun: 'bg-red-800/60 text-red-200 border-red-600',
|
||||||
invalid: 'bg-yellow-800/60 text-yellow-200 border-yellow-600',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatConfig(config) {
|
function formatConfig(config) {
|
||||||
|
|||||||
Reference in New Issue
Block a user