diff --git a/src/torq/api/routes.py b/src/torq/api/routes.py index 1a775c5..beab88e 100644 --- a/src/torq/api/routes.py +++ b/src/torq/api/routes.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse, StreamingResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from torq.config import settings from torq.db import models @@ -19,13 +19,13 @@ class MachineIn(BaseModel): - id: str - model: str - location: str + id: str = Field(max_length=100) + model: str = Field(max_length=200) + location: str = Field(max_length=200) class FaultIn(BaseModel): - fault_code: str + fault_code: str = Field(min_length=1, max_length=50) machine: str = "" context: str = "" translate: bool = True diff --git a/src/torq/config.py b/src/torq/config.py index 0abbc6d..fa7cc73 100644 --- a/src/torq/config.py +++ b/src/torq/config.py @@ -29,17 +29,12 @@ def _ensure_llm_key(self): if not self.llm_api_key: self.llm_api_key = os.environ.get("OPENAI_API_KEY", "") if not self.llm_api_key: - print("=" * 60) - print(" FATAL: No LLM API key found.") - print() - print(" Set LLM_API_KEY or OPENAI_API_KEY in your .env file") - print(" or export it as an environment variable.") - print() - print(" Example:") - print(" LLM_API_KEY=sk-... # DeepSeek (default)") - print(" OPENAI_API_KEY=sk-... # OpenAI-compatible") - print("=" * 60) - sys.exit(1) + import logging + logging.warning( + "No LLM API key found. " + "Set LLM_API_KEY or OPENAI_API_KEY in your .env file. " + "LLM-dependent features (diagnosis, translation) will fall back." + ) return self # Vector DB (Qdrant) diff --git a/src/torq/dispatch/routing.py b/src/torq/dispatch/routing.py index 822406c..3c5fa00 100644 --- a/src/torq/dispatch/routing.py +++ b/src/torq/dispatch/routing.py @@ -11,7 +11,10 @@ def _load_roster(shifts_file: Path | None = None) -> list[dict]: shifts_file = shifts_file or settings.shifts_file if not shifts_file.exists(): return [] - return json.loads(shifts_file.read_text(encoding="utf-8")) + try: + return json.loads(shifts_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] def choose_technician(wo: WorkOrder, roster: list[dict] | None = None) -> dict | None: diff --git a/web/src/ErrorBoundary.jsx b/web/src/ErrorBoundary.jsx new file mode 100644 index 0000000..68ce9df --- /dev/null +++ b/web/src/ErrorBoundary.jsx @@ -0,0 +1,25 @@ +import { Component } from "react"; + +export default class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error) { + return { error }; + } + + render() { + if (this.state.error) { + return ( +
+

Something went wrong

+
{this.state.error.message}
+ +
+ ); + } + return this.props.children; + } +} diff --git a/web/src/main.jsx b/web/src/main.jsx index 46f15cf..efa8804 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App.jsx"; +import ErrorBoundary from "./ErrorBoundary.jsx"; import { I18nProvider } from "./i18n"; import { ToastProvider } from "./toast.jsx"; import "./styles.css"; @@ -11,7 +12,9 @@ ReactDOM.createRoot(document.getElementById("root")).render( - + + + diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index 2f27a01..3166d4f 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -217,6 +217,7 @@ function ActivityLog() { /* ignore malformed frame */ } }); + es.onerror = () => setEntries((prev) => [...prev, { type: "disconnected", detail: "SSE lost" }]); return () => { alive = false; es.close(); @@ -570,10 +571,14 @@ export default function Dashboard() { const [machineDetail, setMachineDetail] = useState(null); const [busy, setBusy] = useState(false); const [loading, setLoading] = useState(true); + const [errored, setErrored] = useState(false); const [statusFilter, setStatusFilter] = useState(""); const [searchQuery, setSearchQuery] = useState(""); + const inflight = useRef(false); const load = useCallback(async () => { + if (inflight.current) return; + inflight.current = true; try { const [m, p, a, ev, trend, fpm] = await Promise.all([ api.getMetrics(), @@ -590,8 +595,12 @@ export default function Dashboard() { setTrendData(trend); setFpmData(fpm); setLoading(false); + setErrored(false); } catch (e) { setLoading(false); + setErrored(true); + } finally { + inflight.current = false; } }, []); @@ -657,6 +666,8 @@ export default function Dashboard() {

{t("dashboard.subtitle")}

+ {errored &&
Could not load data — check connection
} +
{loading ? ( <> diff --git a/web/src/styles.css b/web/src/styles.css index 2130c1c..d948673 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -182,3 +182,37 @@ body { animation: shimmer 1.5s ease-in-out infinite; border-radius: 8px; } + +/* ── Error boundary ── */ + +.errorBoundary { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; + text-align: center; +} +.errorBoundary pre { + color: var(--text-secondary, #666); + max-width: 600px; + overflow-x: auto; +} +.errorBoundary button { + margin-top: 1rem; + padding: 0.6rem 1.5rem; + background: var(--accent, #2563eb); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; +} + +/* ── Dashboard errored state ── */ + +.dashboardError { + text-align: center; + padding: 2rem; + color: var(--text-secondary, #666); +}