feat: add Fleet operations view
This commit is contained in:
1 parent
c6d01df31c
commit
786a1ab910
12 files changed
+1062
-148
No files matched your search
@@ -363,6 +363,7 @@ class FrontendShellTests(unittest.TestCase):
|
||||
"/assets/minerops-app.css",
|
||||
"/assets/minerops-app.js",
|
||||
"/assets/minerops-mine-map.js",
|
||||
"/assets/minerops-fleet.js",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
response = self.client.get(path)
|
||||
@@ -405,12 +406,76 @@ class FrontendShellTests(unittest.TestCase):
|
||||
self.assertIn("element.textContent = values.text", javascript)
|
||||
self.assertIn("cell.textContent = text(content)", javascript)
|
||||
self.assertIn("encodeURIComponent(repair.miner_id)", javascript)
|
||||
self.assertIn("encodeURIComponent(miner.id)", javascript)
|
||||
self.assertIn("new AbortController()", javascript)
|
||||
self.assertIn("invalid repair response", javascript)
|
||||
self.assertIn("invalid inventory response", javascript)
|
||||
self.assertIn("window.location.replace", javascript)
|
||||
|
||||
def test_fleet_page_is_semantic_responsive_and_permission_aware(self):
|
||||
self.authenticate()
|
||||
response = self.client.get("/assets/miners")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
for value in (
|
||||
'id="fleetFilters"', 'id="fleetRows"', 'id="fleetResultSummary"',
|
||||
'<table class="fleet-table">', '<th>Operational</th>',
|
||||
'id="fleetPrevious"', 'id="fleetNext"',
|
||||
'/assets/minerops-fleet.js', 'data-can-view-locations="true"',
|
||||
'id="fleetSite"', 'id="fleetRoom"',
|
||||
):
|
||||
self.assertIn(value, response.text)
|
||||
|
||||
self.authenticate(permissions=("miners.view",))
|
||||
restricted = self.client.get("/assets/miners").text
|
||||
self.assertIn('data-can-view-locations="false"', restricted)
|
||||
self.assertNotIn('id="fleetSite"', restricted)
|
||||
self.assertNotIn('id="fleetRoom"', restricted)
|
||||
|
||||
def test_fleet_reset_action_has_a_visible_label(self):
|
||||
self.authenticate()
|
||||
response = self.client.get("/assets/miners")
|
||||
self.assertIn(
|
||||
'<button class="quiet-button" id="fleetReset" '
|
||||
'type="button">Reset</button>',
|
||||
response.text,
|
||||
)
|
||||
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
".fleet-filter-actions .quiet-button { color: var(--ink); }",
|
||||
css,
|
||||
)
|
||||
|
||||
def test_fleet_script_dom_contract_matches_authorized_page(self):
|
||||
self.authenticate()
|
||||
response = self.client.get("/assets/miners")
|
||||
source = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8")
|
||||
declared = re.search(r"const ids = \[(.*?)\];", source, re.DOTALL)
|
||||
self.assertIsNotNone(declared)
|
||||
required = set(re.findall(r'"([A-Za-z][A-Za-z0-9]+)"', declared.group(1)))
|
||||
required.update(("fleetSite", "fleetRoom"))
|
||||
rendered = set(re.findall(r'\bid="([^"]+)"', response.text))
|
||||
self.assertEqual(required - rendered, set())
|
||||
|
||||
def test_fleet_script_is_bounded_read_only_and_xss_safe(self):
|
||||
source = (STATIC_ROOT / "fleet.js").read_text(encoding="utf-8")
|
||||
self.assertEqual(source.count('fetch("/api/inventory/miners?"'), 1)
|
||||
self.assertNotIn("/api/location/map", source)
|
||||
self.assertNotIn("setInterval", source)
|
||||
for method in ('method: "POST"', 'method: "PATCH"', 'method: "PUT"', 'method: "DELETE"'):
|
||||
self.assertNotIn(method, source)
|
||||
for unsafe in ("innerHTML", "insertAdjacentHTML", "document.write"):
|
||||
self.assertNotIn(unsafe, source)
|
||||
self.assertIn("textContent", source)
|
||||
self.assertIn('"/miners/" + encodeURIComponent(miner.id) + "/detail"', source)
|
||||
self.assertIn('"/monitoring/map?" + params.toString()', source)
|
||||
self.assertNotRegex(source.lower(), r"stale.{0,20}(threshold|minutes|hours)")
|
||||
self.assertNotIn("lowHashrate", source)
|
||||
|
||||
def test_fleet_css_has_status_text_mobile_cards_and_visible_focus(self):
|
||||
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||
self.assertIn('.fleet-row[data-status="offline"]', css)
|
||||
self.assertIn("content: attr(data-label)", css)
|
||||
self.assertIn(".fleet-pagination", css)
|
||||
self.assertIn(":focus-visible", css)
|
||||
|
||||
def test_styles_have_no_motion_to_reduce(self):
|
||||
css = (STATIC_ROOT / "app.css").read_text(encoding="utf-8")
|
||||
self.assertNotIn("animation:", css)
|
||||
|
||||
@@ -45,6 +45,21 @@ def inventory_row(**overrides):
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"tags": [],
|
||||
"site_name": None,
|
||||
"room_name": None,
|
||||
"location_position_id": None,
|
||||
"rack_label": None,
|
||||
"shelf_label": None,
|
||||
"position_label": None,
|
||||
"position_index": None,
|
||||
"observed_at": None,
|
||||
"hashrate_ths": None,
|
||||
"power_w": None,
|
||||
"chip_temp_max_c": None,
|
||||
"hash_stable": None,
|
||||
"active_repair_id": None,
|
||||
"active_repair_status": None,
|
||||
"active_repair_reason": None,
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
@@ -56,6 +71,7 @@ class FakeInventoryRepository:
|
||||
self.created_values = None
|
||||
self.asset_id_history = []
|
||||
self.update_error = None
|
||||
self.search_kwargs = None
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
@@ -89,6 +105,7 @@ class FakeInventoryRepository:
|
||||
return list(self.rows.values())[offset : offset + limit]
|
||||
|
||||
def search_miners(self, **kwargs):
|
||||
self.search_kwargs = kwargs
|
||||
rows = list(self.rows.values())
|
||||
lifecycle = kwargs.get("lifecycle_status")
|
||||
if lifecycle:
|
||||
@@ -416,6 +433,54 @@ class InventoryServiceTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(self.repository.asset_id_history, [])
|
||||
|
||||
def test_fleet_projection_mapping_and_filter_forwarding(self):
|
||||
miner_id = uuid4()
|
||||
repair_id = uuid4()
|
||||
site_id = uuid4()
|
||||
row = inventory_row(
|
||||
id=miner_id,
|
||||
miner_sn="MINER-SN",
|
||||
site_id=site_id,
|
||||
site_name="North Site",
|
||||
observed_at=datetime.now(timezone.utc),
|
||||
hashrate_ths=0.0,
|
||||
power_w=0.0,
|
||||
chip_temp_max_c=0.0,
|
||||
hash_stable=False,
|
||||
active_repair_id=repair_id,
|
||||
active_repair_status="IN_PROGRESS",
|
||||
active_repair_reason="Hashboard diagnostics",
|
||||
)
|
||||
self.repository.rows[miner_id] = row
|
||||
filters = InventorySearchFilters(
|
||||
model=" M50 ", runtime_status="offline", repair="active",
|
||||
operational_state="repair", problems=True, site_id=site_id,
|
||||
limit=25, offset=0,
|
||||
)
|
||||
|
||||
result = self.service.search_miners(
|
||||
filters, include_location_details=True
|
||||
)[0]
|
||||
|
||||
self.assertEqual(result.miner_sn, "MINER-SN")
|
||||
self.assertEqual(result.hashrate_ths, 0.0)
|
||||
self.assertEqual(result.power_w, 0.0)
|
||||
self.assertEqual(result.chip_temp_max_c, 0.0)
|
||||
self.assertFalse(result.hash_stable)
|
||||
self.assertEqual(result.active_repair.id, repair_id)
|
||||
self.assertEqual(self.repository.search_kwargs["model"], "M50")
|
||||
self.assertEqual(self.repository.search_kwargs["site_id"], site_id)
|
||||
self.assertTrue(self.repository.search_kwargs["include_location_details"])
|
||||
|
||||
def test_location_filter_without_projection_is_rejected(self):
|
||||
with self.assertRaisesRegex(
|
||||
InventoryValidationError, "Location filters require"
|
||||
):
|
||||
self.service.search_miners(
|
||||
InventorySearchFilters(site_id=uuid4())
|
||||
)
|
||||
self.assertIsNone(self.repository.search_kwargs)
|
||||
|
||||
def test_search_validation_precedes_repository(self):
|
||||
with self.assertRaises(InventoryValidationError):
|
||||
self.service.search_miners(
|
||||
@@ -485,6 +550,22 @@ class InventoryRepositoryIntegrationTests(unittest.TestCase):
|
||||
miner_id,
|
||||
)
|
||||
|
||||
fleet = self.repository.search_miners(
|
||||
model="M50",
|
||||
runtime_status="unknown",
|
||||
operational_state="unknown",
|
||||
problems=True,
|
||||
include_location_details=True,
|
||||
)
|
||||
self.assertEqual(fleet[0]["id"], miner_id)
|
||||
self.assertEqual(fleet[0]["miner_sn"], "MINER-001")
|
||||
self.assertIsNone(fleet[0]["observed_at"])
|
||||
self.assertIsNone(fleet[0]["location_position_id"])
|
||||
self.assertEqual(
|
||||
self.repository.list_location_filter_options(),
|
||||
{"sites": [], "rooms": []},
|
||||
)
|
||||
|
||||
updated = self.repository.update_miner(
|
||||
miner_id,
|
||||
{"lifecycle_status": "spare", "notes": "Bench stock"},
|
||||
|
||||
@@ -43,16 +43,24 @@ class StubInventoryService:
|
||||
self.history_result: list[InventoryAssetIDHistoryEntry] = []
|
||||
self.history_error: Exception | None = None
|
||||
self.history_miner_id: UUID | None = None
|
||||
self.include_location_details = False
|
||||
self.location_options = {"sites": [], "rooms": []}
|
||||
|
||||
def search_miners(
|
||||
self,
|
||||
filters: InventorySearchFilters,
|
||||
*,
|
||||
include_location_details: bool = False,
|
||||
) -> list[InventoryMinerSummary]:
|
||||
self.search_filters = filters
|
||||
self.include_location_details = include_location_details
|
||||
if self.search_error is not None:
|
||||
raise self.search_error
|
||||
return self.search_result
|
||||
|
||||
def list_location_filter_options(self):
|
||||
return self.location_options
|
||||
|
||||
def create_miner(
|
||||
self,
|
||||
request: InventoryCreateRequest,
|
||||
@@ -110,10 +118,10 @@ def miner_summary(miner_id: UUID | None = None) -> InventoryMinerSummary:
|
||||
|
||||
|
||||
def miner_detail(miner_id: UUID | None = None) -> InventoryMinerDetail:
|
||||
summary = miner_summary(miner_id)
|
||||
values = miner_summary(miner_id).model_dump()
|
||||
values["miner_sn"] = "MINER-001"
|
||||
return InventoryMinerDetail(
|
||||
**summary.model_dump(),
|
||||
miner_sn="MINER-001",
|
||||
**values,
|
||||
control_board_sn="CB-001",
|
||||
psu_serial="PSU-001",
|
||||
)
|
||||
@@ -410,10 +418,77 @@ class InventoryRouterTests(unittest.TestCase):
|
||||
self.assertEqual(body["data"][0]["asset_id"], "ASSET-001")
|
||||
self.assertEqual(
|
||||
body["meta"],
|
||||
{"limit": 100, "offset": 0, "returned": 1},
|
||||
{"limit": 100, "offset": 0, "returned": 1,
|
||||
"location_options": {"sites": [], "rooms": []}},
|
||||
)
|
||||
self.assertIsNone(body["error"])
|
||||
|
||||
def test_list_projection_and_location_permission_are_explicit(self):
|
||||
miner_id = uuid4()
|
||||
site_id = uuid4()
|
||||
room_id = uuid4()
|
||||
position_id = uuid4()
|
||||
self.service.search_result = [
|
||||
InventoryMinerSummary(
|
||||
id=miner_id,
|
||||
asset_id="FLEET-001",
|
||||
model="M50",
|
||||
miner_sn="SN-001",
|
||||
lifecycle_status="active",
|
||||
runtime_status="online",
|
||||
site_id=site_id,
|
||||
site_name="North Site",
|
||||
room_id=room_id,
|
||||
room_name="Room A",
|
||||
location_position_id=position_id,
|
||||
rack_label="Rack 1",
|
||||
shelf_label="Shelf 2",
|
||||
position_index=3,
|
||||
observed_at=datetime.now(timezone.utc),
|
||||
hashrate_ths=120.0,
|
||||
power_w=3400.0,
|
||||
chip_temp_max_c=72.0,
|
||||
hash_stable=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
]
|
||||
self.service.location_options = {
|
||||
"sites": [{"id": site_id, "name": "North Site"}],
|
||||
"rooms": [{"id": room_id, "site_id": site_id, "name": "Room A"}],
|
||||
}
|
||||
|
||||
self.authenticate(("miners.view", "locations.view"))
|
||||
response = self.client.get("/api/inventory/miners")
|
||||
data = response.json()["data"][0]
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(data["miner_sn"], "SN-001")
|
||||
self.assertEqual(data["location_position_id"], str(position_id))
|
||||
self.assertEqual(data["hashrate_ths"], 120.0)
|
||||
self.assertTrue(self.service.include_location_details)
|
||||
self.assertEqual(
|
||||
response.json()["meta"]["location_options"]["sites"][0]["name"],
|
||||
"North Site",
|
||||
)
|
||||
|
||||
self.authenticate(("miners.view",))
|
||||
self.service.search_result = [miner_summary(miner_id)]
|
||||
response = self.client.get("/api/inventory/miners")
|
||||
data = response.json()["data"][0]
|
||||
self.assertIsNone(data["site_id"])
|
||||
self.assertIsNone(data["room_id"])
|
||||
self.assertIsNone(data["location_position_id"])
|
||||
self.assertEqual(response.json()["meta"]["location_options"], {"sites": [], "rooms": []})
|
||||
self.assertFalse(self.service.include_location_details)
|
||||
|
||||
def test_location_filters_require_locations_view(self):
|
||||
self.authenticate(("miners.view",))
|
||||
response = self.client.get(
|
||||
"/api/inventory/miners", params={"site_id": str(uuid4())}
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIsNone(self.service.search_filters)
|
||||
|
||||
def test_empty_list_returns_successful_empty_collection(self):
|
||||
self.authenticate()
|
||||
|
||||
@@ -432,6 +507,11 @@ class InventoryRouterTests(unittest.TestCase):
|
||||
("query", "M50"),
|
||||
("asset_id", "ASSET-001"),
|
||||
("lifecycle_status", "spare"),
|
||||
("model", "M50"),
|
||||
("runtime_status", "offline"),
|
||||
("repair", "active"),
|
||||
("operational_state", "repair"),
|
||||
("problems", "true"),
|
||||
("tags", "Priority"),
|
||||
("tags", "Repair"),
|
||||
("limit", "25"),
|
||||
@@ -445,6 +525,11 @@ class InventoryRouterTests(unittest.TestCase):
|
||||
self.assertEqual(filters.query, "M50")
|
||||
self.assertEqual(filters.asset_id, "ASSET-001")
|
||||
self.assertEqual(filters.lifecycle_status, "spare")
|
||||
self.assertEqual(filters.model, "M50")
|
||||
self.assertEqual(filters.runtime_status, "offline")
|
||||
self.assertEqual(filters.repair, "active")
|
||||
self.assertEqual(filters.operational_state, "repair")
|
||||
self.assertTrue(filters.problems)
|
||||
self.assertEqual(filters.tags, ["Priority", "Repair"])
|
||||
self.assertEqual(filters.limit, 25)
|
||||
self.assertEqual(filters.offset, 50)
|
||||
|
||||
Reference in new issue
Block a user