diff --git a/README.md b/README.md index 3aebab2..91959d9 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,25 @@ TORQ is an event-driven predictive-maintenance engine that turns machine faults into grounded diagnoses, approval-ready work orders, and technician dispatches. -A fault arrives over REST or MQTT. TORQ retrieves relevant OEM guidance and past repairs, asks an OpenAI-compatible model for a structured diagnosis, creates an English/French/Arabic work order, and queues it for supervisor review. Resolved repairs return to the knowledge base so the next diagnosis can reuse what worked. +A fault arrives — from a floor operator, a REST API call, or an MQTT event. TORQ retrieves relevant OEM guidance and past repairs, asks an OpenAI-compatible model for a structured diagnosis, creates an English/French/Arabic work order, and queues it for supervisor review. Resolved repairs return to the knowledge base so the next diagnosis can reuse what worked. + +### Three-tier fault ingestion + +TORQ is designed to work in plants with *any* level of digital connectivity. Faults enter through one of three tiers, each tagged with a `source` label visible in the dashboard: + +| Tier | Method | source tag | Hardware needed | For plants that… | +|------|--------|------------|-----------------|------------------| +| **1 — Manual** | Dashboard form or REST `POST /api/faults` | `manual` | None | Have no digital machine connectivity at all — operators report what they see, hear, or smell. | +| **2 — REST API** | Any system that can `POST /api/faults` | `rest` | Existing IT systems | Already use a CMMS, ERP, or custom maintenance tool that can emit fault events. | +| **3 — MQTT** | MQTT broker subscription | `mqtt` | ESP32 bridge (~$20) or existing SCADA | Want automated ingestion — a $20 ESP32 with clamp-on sensors bridges legacy machines to the broker, or modern PLCs publish directly to MQTT. | + +**Tier 1 is the default.** No retrofitting, no sensors, no PLC integration required. The dashboard's fault-report form is the primary ingestion path. Tiers 2 and 3 layer on as plants grow their digital footprint. ## Why TORQ? | Traditional maintenance flow | TORQ | | --- | --- | -| Faults wait to be noticed and triaged | REST and MQTT fault ingestion starts the workflow immediately | +| Faults wait to be noticed and triaged | Operators report faults from the dashboard in seconds — REST and MQTT are optional add-ons | | Technicians search manuals and logs by hand | Dense + BM25 retrieval surfaces relevant guidance and repair history | | Diagnosis quality depends on who is available | Structured, source-aware AI diagnosis creates a consistent starting point | | Work orders are manually written and translated | Approval-ready EN/FR/AR work orders and PDFs are generated automatically | @@ -139,7 +151,7 @@ When a repair is marked resolved, TORQ records the actual fix, technician notes, ### Supervisor and operations views -The built-in dashboard supports fault simulation, approval, rejection, resolution, and downtime metrics. A separate React dashboard adds work-order details, multilingual content, PDF downloads, retrieval evaluation charts, and an editable ROI calculator. +The built-in dashboard supports manual fault reporting, approval, rejection, resolution, and downtime metrics. A separate React dashboard adds work-order details, multilingual content (with source badges — manual vs. mqtt), PDF downloads, retrieval evaluation charts, and an editable ROI calculator. ### Multiple integration surfaces diff --git a/src/torq/agent/schemas.py b/src/torq/agent/schemas.py index 160c314..463f90a 100644 --- a/src/torq/agent/schemas.py +++ b/src/torq/agent/schemas.py @@ -38,6 +38,7 @@ class WorkOrder(BaseModel): status: str = "pending" # pending | approved | rejected | dispatched | resolved | failed assigned_to: str | None = None confidence: float = 0.5 + source: str = "manual" fault_arrived_at: str | None = None created_at: str = Field(default_factory=_now) dispatched_at: str | None = None diff --git a/src/torq/api/main.py b/src/torq/api/main.py index e122a99..5b2c055 100644 --- a/src/torq/api/main.py +++ b/src/torq/api/main.py @@ -6,6 +6,8 @@ from contextlib import asynccontextmanager from typing import AsyncIterator +from pathlib import Path + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse @@ -24,9 +26,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: models.init_db() mqtt_client = None - if settings.enable_fallbacks: - print("[MQTT] fallbacks enabled, skipping broker connection") - else: + if settings.mqtt_broker_url: mqtt_client = listener.build_client() live.mqtt_client = mqtt_client try: @@ -36,7 +36,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: keepalive=60, ) mqtt_client.loop_start() - print("[MQTT] background listener started") + print(f"[MQTT] listening on {settings.mqtt_broker_url}:{settings.mqtt_port}") except Exception as exc: print(f"[MQTT] broker unreachable ({exc}), continuing without live feed") mqtt_client = None @@ -64,6 +64,11 @@ def dashboard() -> str: return DASHBOARD_HTML +@app.get("/operator/report", response_class=HTMLResponse) +def operator_report() -> str: + return Path(__file__).parent.parent.joinpath("operator", "report.html").read_text(encoding="utf-8") + + DASHBOARD_HTML = r""" TORQ Dashboard @@ -101,7 +106,7 @@ def dashboard() -> str:

TORQ Fault-to-Fix — Supervisor Dashboard

- MQTT disconnected +
@@ -109,7 +114,10 @@ def dashboard() -> str:

⚡ LIVE FAULT FEED

Waiting for faults…
- +
+ 📱 Report a fault (open on phone) + Select machine → enter fault code → submitted +

PENDING APPROVAL

ALL WORK ORDERS

@@ -118,11 +126,6 @@ def dashboard() -> str: const post = p => fetch('/api'+p,{method:'POST'}).then(r=>r.json()); const esc = s => String(s).replace(/[&<>"]/g,function(m){return{'&':'&','<':'<','>':'>','"':'"'}[m]||m}); -async function simulate(){ - await fetch('/api/faults',{method:'POST',headers:{'Content-Type':'application/json'}, - body:JSON.stringify({fault_code:'E-471',machine:'CM-350 Line 2',context:'Motor tripped after hours running.'})}); - load(); -} async function approve(id){await post('/work-orders/'+id+'/approve');load();} async function reject(id){await post('/work-orders/'+id+'/reject');load();} async function resolve(id){ @@ -186,5 +189,18 @@ def dashboard() -> str: ''+(w.status==='dispatched'?'':'')+'').join(''); } load(); setInterval(load, 4000); + +// Show MQTT status (configured/connected/not configured) +fetch('/api/health').then(r=>r.json()).then(h=>{ + const m = h.integrations?.mqtt_broker || {}; + const el = document.getElementById('mqtt-status'); + if (!m.configured) { + el.innerHTML = 'MQTT — not configured'; + } else { + const dot = m.connected ? 'connected' : 'disconnected'; + const label = m.connected ? 'connected' : 'disconnected'; + el.innerHTML = ' MQTT '+label+''; + } +}).catch(()=>{}); """ diff --git a/src/torq/api/routes.py b/src/torq/api/routes.py index e6677a8..a49db61 100644 --- a/src/torq/api/routes.py +++ b/src/torq/api/routes.py @@ -19,6 +19,8 @@ router = APIRouter() +_FAULT_CODES_CACHE: list[dict[str, str]] | None = None + class MachineIn(BaseModel): id: str = Field(max_length=100) @@ -31,6 +33,7 @@ class FaultIn(BaseModel): machine: str = "" context: str = "" translate: bool = True + source: str = "manual" class OutcomeIn(BaseModel): @@ -53,6 +56,31 @@ def machine(machine_id: str) -> dict[str, Any]: return registered +@router.get("/fault-codes") +def fault_codes() -> list[dict[str, str]]: + """Return known fault codes from the scenarios file — used by the operator report form.""" + global _FAULT_CODES_CACHE + if _FAULT_CODES_CACHE is not None: + return _FAULT_CODES_CACHE + try: + scenarios = json.loads(settings.scenarios_file.read_text(encoding="utf-8")) + seen: set[str] = set() + result: list[dict[str, str]] = [] + for s in scenarios: + code = s.get("fault_code", "").strip() + if code and code not in seen: + seen.add(code) + result.append({ + "fault_code": code, + "machine": s.get("machine", ""), + "description": s.get("expected_topic", ""), + }) + _FAULT_CODES_CACHE = result + return result + except (OSError, json.JSONDecodeError): + return [] + + @router.post("/machines", status_code=201) def create_machine(machine: MachineIn) -> dict[str, Any]: machine_id = machine.id.strip() @@ -71,7 +99,7 @@ def report_fault(f: FaultIn) -> WorkOrder: arrival = datetime.now(timezone.utc).isoformat() return handle_fault( f.fault_code, f.machine, f.context, - translate=f.translate, fault_arrived_at=arrival, + translate=f.translate, fault_arrived_at=arrival, source=f.source, ) @@ -280,13 +308,14 @@ async def _check_twilio() -> bool: return False -async def _check_mqtt() -> bool: - if live.mqtt_client is None: - return False +async def _check_mqtt() -> dict[str, bool]: + configured = bool(settings.mqtt_broker_url) + if not configured: + return {"configured": False, "connected": False} try: - return live.mqtt_client.is_connected() + return {"configured": True, "connected": live.mqtt_client is not None and live.mqtt_client.is_connected()} except Exception: - return False + return {"configured": True, "connected": False} @router.get("/health") @@ -302,7 +331,7 @@ async def health_check() -> dict[str, Any]: qdrant_ok = await qdrant_task llm_ok = await llm_task twilio_ok = await twilio_task - mqtt_ok = await mqtt_task + mqtt_status = await mqtt_task overall_healthy = db_ok @@ -324,9 +353,9 @@ async def health_check() -> dict[str, Any]: "connected": twilio_ok, }, "mqtt_broker": { - "connected": mqtt_ok, - "broker": settings.mqtt_broker_url, - "fallbacks_active": settings.enable_fallbacks, + "configured": mqtt_status["configured"], + "connected": mqtt_status["connected"], + "broker": settings.mqtt_broker_url if settings.mqtt_broker_url else "(not configured)", }, }, } diff --git a/src/torq/config.py b/src/torq/config.py index c64a1ab..49da74b 100644 --- a/src/torq/config.py +++ b/src/torq/config.py @@ -46,11 +46,10 @@ def _ensure_llm_key(self): twilio_auth_token: str = "" twilio_whatsapp_from: str = "" - # MQTT (machine fault events). Public broker by default; $0, no auth. - # Public unauthenticated broker + fixed topic means anyone can publish fake - # faults (each spawns a diagnosis run) or read plant data. For production, - # point at an authenticated broker over TLS and set credentials. - mqtt_broker_url: str = "broker.hivemq.com" + # MQTT (machine fault events). Empty by default — purely opt-in. + # Set MQTT_BROKER_URL to enable the listener for plants with digital + # machine connectivity. For authenticated brokers, also set credentials. + mqtt_broker_url: str = "" mqtt_port: int = 1883 mqtt_topic: str = "torq/demo/faults" diff --git a/src/torq/db/models.py b/src/torq/db/models.py index 00a76f9..d44bea9 100644 --- a/src/torq/db/models.py +++ b/src/torq/db/models.py @@ -1,7 +1,7 @@ """Persistence for work orders and the machine registry.""" import json -from datetime import datetime +from datetime import datetime, timezone from typing import Any from torq.agent.schemas import WorkOrder @@ -122,6 +122,25 @@ def list_all() -> list[WorkOrder]: return [WorkOrder.model_validate_json(r["data"]) for r in rows] +def find_recent(machine: str, fault_code: str, window_sec: int = 30) -> WorkOrder | None: + """Return the most recent work order for (machine, fault_code) within window_sec, if any.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT data FROM work_orders WHERE machine = ? AND fault_code = ? ORDER BY rowid DESC LIMIT 1", + (machine, fault_code), + ).fetchall() + if not rows: + return None + wo = WorkOrder.model_validate_json(rows[0]["data"]) + if not wo.created_at: + return None + try: + age = (datetime.now(timezone.utc) - datetime.fromisoformat(wo.created_at)).total_seconds() + except ValueError: + return None + return wo if age <= window_sec else None + + def _secs(start: str | None, end: str | None) -> float | None: if not start or not end: return None diff --git a/src/torq/events/listener.py b/src/torq/events/listener.py index b117005..f1d8835 100644 --- a/src/torq/events/listener.py +++ b/src/torq/events/listener.py @@ -39,6 +39,7 @@ def handle_payload(payload: bytes) -> WorkOrder | None: machine=event.machine_id, context=event.context, fault_arrived_at=arrival, + source="mqtt", ) sev = MachineFaultEvent.SEVERITY_LABELS.get(event.severity, "unknown") print( diff --git a/src/torq/operator/report.html b/src/torq/operator/report.html new file mode 100644 index 0000000..3edd0dc --- /dev/null +++ b/src/torq/operator/report.html @@ -0,0 +1,215 @@ + + + + + + +TORQ — Report a Fault + + + +
+ +

Select the machine, enter the fault code, and submit. The diagnosis and work order are generated automatically.

+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+ +
+
+
Fault reported
+
Diagnosis in progress. Work order will be queued for supervisor review.
+ Report another fault +
+
+ + + + + \ No newline at end of file diff --git a/src/torq/pipeline.py b/src/torq/pipeline.py index 0134456..d982c74 100644 --- a/src/torq/pipeline.py +++ b/src/torq/pipeline.py @@ -4,6 +4,7 @@ from torq.agent.diagnose import diagnose from torq.agent.schemas import WorkOrder +from torq.db import models from torq.dispatch import approval from torq.events import live from torq.workorder.generate import build_work_order @@ -15,13 +16,19 @@ def handle_fault( context: str = "", translate: bool = True, fault_arrived_at: str | None = None, + source: str = "manual", ) -> WorkOrder: """Fault event -> diagnosis -> work order -> queued for supervisor approval.""" if fault_arrived_at is None: fault_arrived_at = datetime.now(timezone.utc).isoformat() + + existing = models.find_recent(machine, fault_code) if machine else None + if existing: + return existing + live.push_activity("fault_received", machine, fault_code) live.push_activity("diagnosing", machine, fault_code, detail="reading manuals + repair history") diag = diagnose(fault_code, machine, context) - wo = build_work_order(diag, translate=translate, fault_arrived_at=fault_arrived_at) + wo = build_work_order(diag, translate=translate, fault_arrived_at=fault_arrived_at, source=source) live.push_activity("work_order_created", machine, fault_code, detail=diag.root_cause, wo_id=wo.id) return approval.submit(wo) diff --git a/src/torq/workorder/generate.py b/src/torq/workorder/generate.py index 660efda..d9994ca 100644 --- a/src/torq/workorder/generate.py +++ b/src/torq/workorder/generate.py @@ -68,7 +68,8 @@ def _translate(en_text: str) -> dict[str, str]: def build_work_order( - diag: Diagnosis, translate: bool = True, fault_arrived_at: str | None = None + diag: Diagnosis, translate: bool = True, fault_arrived_at: str | None = None, + source: str = "manual", ) -> WorkOrder: en = _render_en(diag) content = {"en": en} @@ -88,5 +89,6 @@ def build_work_order( investigation=diag.investigation, content=content, confidence=diag.confidence, + source=source, fault_arrived_at=fault_arrived_at, ) diff --git a/uv.lock b/uv.lock index 9817848..051dfa8 100644 --- a/uv.lock +++ b/uv.lock @@ -204,6 +204,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -2908,6 +2917,7 @@ name = "torq" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "cachetools" }, { name = "chonkie" }, { name = "fastapi" }, { name = "fpdf2" }, @@ -2933,6 +2943,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cachetools", specifier = ">=5.5.0" }, { name = "chonkie", specifier = ">=1.7.0" }, { name = "fastapi", specifier = ">=0.139.2" }, { name = "fpdf2", specifier = ">=2.8.7" }, diff --git a/web/src/api.js b/web/src/api.js index c6a710f..52bc52a 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -43,4 +43,6 @@ export const recordOutcome = (id, body) => body: JSON.stringify(body), }); +export const getMachines = () => j("/machines").catch(() => []); + export const getHealth = () => j("/health").catch(() => null); diff --git a/web/src/i18n.jsx b/web/src/i18n.jsx index 7b55a8a..be9f1c3 100644 --- a/web/src/i18n.jsx +++ b/web/src/i18n.jsx @@ -33,11 +33,11 @@ const LOCALES = { "landing.feature_4_title": "Self Learning", "landing.feature_4_desc": "Every fix enriches the knowledge base. The model gets smarter with each resolved fault.", "landing.feature_5_title": "Works With Your Setup", - "landing.feature_5_desc": "Report faults from the dashboard, connect machines that send digital alerts, or bridge older equipment — no special hardware required.", + "landing.feature_5_desc": "Report faults from the dashboard — select a machine, enter the code, describe what you see. No sensors, no PLCs, no hardware required.", "landing.how_title": "How it works", "landing.how_subtitle": "Three steps from alarm to resolution.", "landing.step_1_title": "A fault comes in", - "landing.step_1_desc": "A technician reports it from the dashboard, a connected machine sends an alert, or a bridge picks it up from older controllers. TORQ starts immediately, no matter how the fault arrives.", + "landing.step_1_desc": "A floor operator reports it from the dashboard — select the machine, enter the fault code, describe what you see. TORQ starts immediately. No sensors, no PLCs, no hardware required.", "landing.step_2_title": "AI diagnoses against manuals + history", "landing.step_2_desc": "Hybrid search (dense + BM25 + reranker) retrieves relevant documentation. A reasoning LLM produces a grounded diagnosis with citations.", "landing.step_3_title": "Supervisor approves → technician routed", @@ -98,7 +98,7 @@ const LOCALES = { "faq.q3": "What machines are supported?", "faq.a3": "Any machine. If your equipment sends digital alerts, TORQ can pick them up. If your machines are fully offline, operators can submit faults directly through the dashboard form or from any connected device.", "faq.q4": "How do I integrate TORQ with my plant?", - "faq.a4": "Four ways: (1) Use the dashboard form to report a fault manually. (2) Send fault data from your existing maintenance or ERP system. (3) Connect machines that already produce digital fault signals. (4) Run a small bridge on a device like a Raspberry Pi to connect older controllers \u2014 no special hardware required.", + "faq.a4": "Three tiers, no hardware needed for Tier 1: (1) Use the dashboard form to report a fault manually \u2014 select a machine, enter the code, describe what you see. (2) Connect machines that publish MQTT fault events (a USD 20 ESP32 bridge adapts legacy equipment). (3) Use the REST API to integrate with your existing CMMS, ERP, or custom maintenance tool.", "faq.q5": "Is my data secure?", "faq.a5": "TORQ runs on your infrastructure. LLM calls go to your endpoint. Vector DB can be self-hosted. No data leaves your network.", "faq.q6": "What languages are supported?", @@ -130,12 +130,14 @@ const LOCALES = { "dashboard.close": "close", "dashboard.download_pdf": "Download PDF (EN/FR/AR)", "dashboard.grounded_in": "Grounded in", - "dashboard.simulate": "Simulate fault (E-471, CM-350 Line 2)", "dashboard.report_fault": "Report a fault", - "dashboard.machine_placeholder": "Machine (e.g. CM-350 Line 2)", + "dashboard.manual_form_hint": "Select a machine, enter the fault code, and describe what you see or hear.", + "dashboard.machine_placeholder": "Select machine…", "dashboard.fault_code_placeholder": "Fault code (e.g. E-471)", "dashboard.context_placeholder": "Context / observations (optional)", "dashboard.submit_fault": "Submit fault", + "dashboard.operator_link": "Mobile report", + "dashboard.operator_link_hint": "Open on any phone →", "dashboard.no_trend_data": "No trend data yet", "dashboard.no_machine_data": "No machine data yet", "dashboard.no_recent_faults": "No recent faults detected", @@ -179,7 +181,7 @@ const LOCALES = { "dashboard.toast_dispatched": "Dispatched to", "dashboard.toast_rejected": "Rejected", "dashboard.toast_fixed": "Marked as fixed", - "dashboard.toast_simulated": "Fault simulated", + "dashboard.toast_fault_reported": "Fault reported", "dashboard.stage_fault_received": "Fault detected", "dashboard.stage_diagnosing": "Diagnosing", "dashboard.stage_work_order_created": "Work order ready", @@ -337,12 +339,14 @@ const LOCALES = { "dashboard.close": "fermer", "dashboard.download_pdf": "Télécharger PDF (EN/FR/AR)", "dashboard.grounded_in": "Basé sur", - "dashboard.simulate": "Simuler défaut (E-471, CM-350 Line 2)", "dashboard.report_fault": "Signaler un défaut", - "dashboard.machine_placeholder": "Machine (ex. CM-350 Line 2)", + "dashboard.manual_form_hint": "Sélectionnez une machine, saisissez le code défaut, et décrivez ce que vous voyez ou entendez.", + "dashboard.machine_placeholder": "Sélectionner une machine…", "dashboard.fault_code_placeholder": "Code défaut (ex. E-471)", "dashboard.context_placeholder": "Contexte / observations (optionnel)", "dashboard.submit_fault": "Soumettre le défaut", + "dashboard.operator_link": "Signalement mobile", + "dashboard.operator_link_hint": "Ouvrir sur un téléphone →", "dashboard.no_trend_data": "Aucune donnée de tendance", "dashboard.no_machine_data": "Aucune donnée machine", "dashboard.no_recent_faults": "Aucun défaut récent détecté", @@ -386,7 +390,7 @@ const LOCALES = { "dashboard.toast_dispatched": "Envoyé à", "dashboard.toast_rejected": "Rejeté", "dashboard.toast_fixed": "Marqué comme réparé", - "dashboard.toast_simulated": "Défaut simulé", + "dashboard.toast_fault_reported": "Défaut signalé", "dashboard.stage_fault_received": "Défaut détecté", "dashboard.stage_diagnosing": "Diagnostic en cours", "dashboard.stage_work_order_created": "Ordre de travail prêt", @@ -544,12 +548,14 @@ const LOCALES = { "dashboard.close": "إغلاق", "dashboard.download_pdf": "تنزيل PDF (EN/FR/AR)", "dashboard.grounded_in": "مستند على", - "dashboard.simulate": "محاكاة خلل (E-471, CM-350 Line 2)", "dashboard.report_fault": "الإبلاغ عن خلل", - "dashboard.machine_placeholder": "الآلة (مثل CM-350 Line 2)", + "dashboard.manual_form_hint": "اختر آلة، أدخل رمز الخلل، وصف ما تراه أو تسمعه.", + "dashboard.machine_placeholder": "اختر آلة…", "dashboard.fault_code_placeholder": "رمز الخلل (مثل E-471)", "dashboard.context_placeholder": "السياق / الملاحظات (اختياري)", "dashboard.submit_fault": "إرسال الخلل", + "dashboard.operator_link": "إبلاغ من الهاتف", + "dashboard.operator_link_hint": "فتح على أي هاتف →", "dashboard.no_trend_data": "لا توجد بيانات اتجاه بعد", "dashboard.no_machine_data": "لا توجد بيانات آلة بعد", "dashboard.no_recent_faults": "لم يتم اكتشاف أعطال حديثة", @@ -593,7 +599,7 @@ const LOCALES = { "dashboard.toast_dispatched": "تم الإرسال إلى", "dashboard.toast_rejected": "مرفوض", "dashboard.toast_fixed": "تم الإصلاح", - "dashboard.toast_simulated": "تم محاكاة الخلل", + "dashboard.toast_fault_reported": "تم الإبلاغ عن الخلل", "dashboard.stage_fault_received": "تم اكتشاف العطل", "dashboard.stage_diagnosing": "جارٍ التشخيص", "dashboard.stage_work_order_created": "أمر العمل جاهز", diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index 4eb3312..291f655 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -19,18 +19,6 @@ const FIX = { time_to_fix_min: 30, }; -// Varied demo faults across skills (E=electromechanical, J=packaging, C/P/A=general) -// so repeated clicks exercise different machines and route to different techs. -const SIM_FAULTS = [ - { fault_code: "E-471", machine: "CM-350 Line 2", context: "Motor tripped after hours running." }, - { fault_code: "E-201", machine: "CM-350 Line 1", context: "Overcurrent on start, grinding noise." }, - { fault_code: "J-108", machine: "PK-9 Line 3", context: "Film feed jammed at the roller nip." }, - { fault_code: "J-233", machine: "PK-9 Line 3", context: "Seal temperature low, weak seals." }, - { fault_code: "C-207", machine: "Chiller 6", context: "Condenser water flow dropping, efficiency down." }, - { fault_code: "P-410", machine: "Pump 3", context: "High vibration and bearing noise." }, - { fault_code: "A-120", machine: "AHU 2", context: "Filter differential pressure over setpoint." }, -]; - function Tile({ label, value }) { return (
@@ -44,6 +32,12 @@ function Badge({ status }) { return {status}; } +function SourceBadge({ source }) { + if (!source) return null; + const icon = source === "manual" ? "👤" : "📡"; + return {icon} {source}; +} + // A work order is "open" until it reaches a terminal state. const OPEN_STATUS = new Set(["pending", "approved", "dispatched"]); const fmtDate = (iso) => (iso ? new Date(iso).toLocaleString() : "-"); @@ -548,35 +542,39 @@ function StatusFilter({ value, onChange, t }) { } function ManualFaultForm({ onReport, busy, t }) { - const [open, setOpen] = useState(false); const [machine, setMachine] = useState(""); const [faultCode, setFaultCode] = useState(""); const [context, setContext] = useState(""); + const [machines, setMachines] = useState([]); + + useEffect(() => { + api.getMachines().then(setMachines).catch(() => {}); + }, []); const handleSubmit = (e) => { e.preventDefault(); if (!machine || !faultCode) return; - onReport({ machine, fault_code: faultCode, context }); + onReport({ machine, fault_code: faultCode, context, source: "manual" }); setMachine(""); setFaultCode(""); setContext(""); - setOpen(false); }; return (
- - {open && ( -
- +
+ setFaultCode(e.target.value)} required /> -