Compare commits

...

3 Commits

Author SHA1 Message Date
0696f16458 Merge pull request 'fix/spa-reconnect-no-demo-data' (#1) from fix/spa-reconnect-no-demo-data into main
Reviewed-on: #1
2026-07-09 10:57:30 +00:00
979aae426b perf(db): batch the good-parts N+1 + cache the press/reason reference reads
- _good_parts_map: collapse the per-press 3-query loop (3xN) into 3 grouped
  queries across all presses; semantics preserved (Joborder JSON/substring
  matching, per-press scoping, whole-map degrade-to-{} on error).
- _press_list + /reasons: served from the kernel cached_select() TTL cache.

Part of the gateway connection-pool-saturation fix
(see fastAPI/agent_docs/db-pool-relief.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:06:49 +02:00
07c6789e03 fix(spa): reconnect spinner on connection loss, no demo data in prod
On a failed fetch the store keeps the last real payload (stale > fake) and
flags `disconnected` instead of falling back to bundled demo carts — an
operator must never book a reject against a fabricated entry. Sample data and
sample reason codes are gated behind import.meta.env.DEV, so `vite build`
tree-shakes them out entirely (verified: no sample strings in dist).

- cold start / no data: centered reconnect spinner that keeps retrying
- data present + drop: stale carts stay, header shows amber "reconnecting"
- reasons no longer seed sample codes in production
- polling retries faster (5s) while disconnected, recovers automatically

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:20:34 +02:00
12 changed files with 217 additions and 118 deletions

4
_db.py
View File

@ -18,9 +18,9 @@ from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
from app.db.mariadb import engine, run_select_query # noqa: F401 (kernel DB layer) from app.db.mariadb import engine, run_select_query, cached_select # noqa: F401 (kernel DB layer)
__all__ = ["run_select_query", "transaction", "engine"] __all__ = ["run_select_query", "cached_select", "transaction", "engine"]
@contextmanager @contextmanager

View File

@ -51,7 +51,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import text from sqlalchemy import text
from starlette import status from starlette import status
from .._db import run_select_query, transaction from .._db import run_select_query, cached_select, transaction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -96,7 +96,9 @@ def _press_list(plant: str, entry_presses: set[str]) -> list[str]:
if the registry is unavailable. Numeric press ids sort numerically.""" if the registry is unavailable. Numeric press ids sort numerically."""
presses: set[str] = set(entry_presses) presses: set[str] = set(entry_presses)
try: try:
rows = run_select_query( # Reference registry (admin-edited); served from cache so it's off the pool each poll.
rows = cached_select(
f"trj:presses:{plant}",
"SELECT psPress AS p FROM press_settings.presses WHERE psPlant = :plant", "SELECT psPress AS p FROM press_settings.presses WHERE psPlant = :plant",
{"plant": plant}, {"plant": plant},
) )
@ -172,51 +174,58 @@ def _good_parts_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str,
return {} return {}
import json import json
by_press: dict[str, list[str]] = defaultdict(list) # Batched across ALL presses: 3 grouped queries total (was 3 per press = N+1).
for p, o in pairs: # Semantics preserved from the per-press version — only the query scope changed
by_press[p].append(o) # (grouped by press). On any DB error the whole map degrades to {} (the caller's
# _flow_for then treats notYetSent as 0), matching the sibling _temper_agg_map.
result: dict[tuple[str, str], int] = {} presses = sorted({p for p, _ in pairs})
for press, orders in by_press.items(): orders = sorted({o for _, o in pairs})
try: pbinds = {f"p{i}": p for i, p in enumerate(presses)}
obinds: dict[str, Any] = {"plant": plant, "press": press} obinds = {f"o{i}": o for i, o in enumerate(orders)}
for i, o in enumerate(orders): pin = ", ".join(f":p{i}" for i in range(len(presses)))
obinds[f"o{i}"] = o
oin = ", ".join(f":o{i}" for i in range(len(orders))) oin = ", ".join(f":o{i}" for i in range(len(orders)))
try:
# 1) cavities + target per active order (article/qty snapshot). # 1) cavities per (press, order) — one query.
active = run_select_query( cav_by: dict[tuple[str, str], int] = {
"SELECT aoOrdernumber AS o, aoCavities AS cav FROM production.active_orders " (str(r["press"]), str(r["o"])): int(r["cav"] or 0)
f"WHERE aoPlant = :plant AND aoPress = :press AND aoOrdernumber IN ({oin})", for r in run_select_query(
obinds, "SELECT aoPress AS press, aoOrdernumber AS o, aoCavities AS cav "
"FROM production.active_orders "
f"WHERE aoPlant = :plant AND aoPress IN ({pin}) AND aoOrdernumber IN ({oin})",
{"plant": plant, **pbinds, **obinds},
) )
cav_by_order = {str(r["o"]): int(r["cav"] or 0) for r in active} }
if not cav_by_order: if not cav_by:
continue # none of these orders are active → notYetSent stays 0 return {} # none of these orders are active → notYetSent stays 0
# 2) cycle counts grouped by Joborder (one scan for the press). # 2) cycle counts grouped by (Press, Joborder) — one scan. Joborder is unfiltered
cyc_groups = [ # (a plain order number OR a JSON array), matched to orders in Python below.
(cr["jo"], int(cr["c"] or 0)) cyc_by_press: dict[str, list[tuple[Any, int]]] = defaultdict(list)
for cr in run_select_query( for cr in run_select_query(
"SELECT Joborder AS jo, COUNT(*) AS c FROM production.cycles_new " "SELECT Press AS press, Joborder AS jo, COUNT(*) AS c "
"WHERE Press = :press GROUP BY Joborder", f"FROM production.cycles_new WHERE Press IN ({pin}) GROUP BY Press, Joborder",
{"press": press}, pbinds,
)
]
# 3) rejects summed per order (one grouped query).
rej_by_order: dict[str, int] = {}
for rr in run_select_query(
"SELECT rejOrdernumber AS o, COALESCE(SUM(rejQuantity),0) AS total "
"FROM production.rejects WHERE rejPress = :press "
f"AND rejOrdernumber IN ({oin}) GROUP BY rejOrdernumber",
obinds,
): ):
rej_by_order[str(rr["o"])] = int(rr["total"] or 0) cyc_by_press[str(cr["press"])].append((cr["jo"], int(cr["c"] or 0)))
def _cycles_for(orderno: str) -> int: # 3) rejects summed per (press, order) — one grouped query.
rej_by: dict[tuple[str, str], int] = {
(str(rr["press"]), str(rr["o"])): int(rr["total"] or 0)
for rr in run_select_query(
"SELECT rejPress AS press, rejOrdernumber AS o, "
"COALESCE(SUM(rejQuantity),0) AS total FROM production.rejects "
f"WHERE rejPress IN ({pin}) AND rejOrdernumber IN ({oin}) "
"GROUP BY rejPress, rejOrdernumber",
{**pbinds, **obinds},
)
}
except Exception as e: # noqa: BLE001 — whole map degrades to {} (matches _temper_agg_map)
logger.warning("good-parts derivation failed: %s", e)
return {}
def _cycles_for(press: str, orderno: str) -> int:
total = 0 total = 0
for jo, c in cyc_groups: for jo, c in cyc_by_press.get(press, ()):
if jo is None: if jo is None:
continue continue
if jo == orderno: if jo == orderno:
@ -230,13 +239,12 @@ def _good_parts_map(plant: str, pairs: set[tuple[str, str]]) -> dict[tuple[str,
total += c total += c
return total return total
for orderno, cav in cav_by_order.items(): result: dict[tuple[str, str], int] = {}
produced = _cycles_for(orderno) * cav for (press, orderno), cav in cav_by.items():
good = max(0, produced - rej_by_order.get(orderno, 0)) if (press, orderno) not in pairs: # keep emitted keys identical to the input pairs
result[(press, orderno)] = good
except Exception as e: # noqa: BLE001 — degrade this press to no notYetSent
logger.warning("good-parts derivation failed for press %s: %s", press, e)
continue continue
produced = _cycles_for(press, orderno) * cav
result[(press, orderno)] = max(0, produced - rej_by.get((press, orderno), 0))
return result return result
@ -460,7 +468,9 @@ def reasons() -> dict[str, Any]:
"""Active reject reasons — same query as fastpress ``get_reject_reasons``. """Active reject reasons — same query as fastpress ``get_reject_reasons``.
Global (not press-scoped). Read-only.""" Global (not press-scoped). Read-only."""
try: try:
rows = run_select_query( # Reference data (admin-edited); served from cache.
rows = cached_select(
"trj:reject_reasons",
"SELECT id, reason_de, reason_en FROM settings.reject_reasons " "SELECT id, reason_de, reason_en FROM settings.reject_reasons "
"WHERE active = 1 ORDER BY id", "WHERE active = 1 ORDER BY id",
{}, {},

1
dist/assets/index-CU7fLO-K.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -9,8 +9,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausschuss-Station</title> <title>Ausschuss-Station</title>
<script type="module" crossorigin src="/module/temper_rejects/assets/index-DceU8_gJ.js"></script> <script type="module" crossorigin src="/module/temper_rejects/assets/index-CjCewD1P.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Oxhdj_dg.css"> <link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-CU7fLO-K.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { WindowCode } from '@/types/station' import type { WindowCode } from '@/types/station'
defineProps<{ window: WindowCode; sample: boolean }>() defineProps<{ window: WindowCode; sample: boolean; disconnected?: boolean }>()
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>() const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
const { t } = useI18n() const { t } = useI18n()
@ -32,7 +32,8 @@ onUnmounted(() => {
</span> </span>
<span v-if="sample" class="demo">{{ t('app.demo') }}</span> <span v-if="sample" class="demo">{{ t('app.demo') }}</span>
<span class="spacer"></span> <span class="spacer"></span>
<span class="live"><span class="led"></span>{{ t('app.live') }}</span> <span v-if="disconnected" class="reconnect"><span class="spinner sm" aria-hidden="true"></span>{{ t('conn.reconnecting') }}</span>
<span v-else class="live"><span class="led"></span>{{ t('app.live') }}</span>
<span class="clock num">{{ clock }}</span> <span class="clock num">{{ clock }}</span>
<span class="rangep" role="group" :aria-label="t('window.label')"> <span class="rangep" role="group" :aria-label="t('window.label')">
<span class="rl">{{ t('window.label') }}</span> <span class="rl">{{ t('window.label') }}</span>
@ -117,6 +118,16 @@ onUnmounted(() => {
background: var(--done); background: var(--done);
animation: blink 2.4s ease-in-out infinite; animation: blink 2.4s ease-in-out infinite;
} }
.reconnect {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--transit);
}
.clock { .clock {
font-size: 0.95rem; font-size: 0.95rem;
color: var(--ink-dim); color: var(--ink-dim);

View File

@ -6,6 +6,11 @@ export default {
live: 'Live', live: 'Live',
demo: 'Demo-Daten', demo: 'Demo-Daten',
}, },
conn: {
connecting: 'Verbinde mit Server …',
lost: 'Verbindung verloren neuer Versuch …',
reconnecting: 'Verbinde neu …',
},
filter: { filter: {
title: 'Maschinen', title: 'Maschinen',
all: 'Alle', all: 'Alle',

View File

@ -7,6 +7,11 @@ export default {
live: 'Live', live: 'Live',
demo: 'Sample data', demo: 'Sample data',
}, },
conn: {
connecting: 'Connecting to server …',
lost: 'Connection lost retrying …',
reconnecting: 'Reconnecting …',
},
filter: { filter: {
title: 'Presses', title: 'Presses',
all: 'All', all: 'All',

View File

@ -6,7 +6,12 @@ import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station' import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling(). // Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
// Falls back to bundled sample data when the API is unreachable (dev / no gateway). //
// Connection loss on this LIVE terminal: never fabricate carts. A failed fetch keeps
// the last known REAL payload (stale > fake — an operator must never book against a
// demo cart) and flips `disconnected`, so the UI shows a reconnect spinner and polling
// retries faster. Bundled sample data renders ONLY under `npm run dev` (no gateway) —
// gated behind `import.meta.env.DEV`, so `vite build` strips it from production.
const CACHE_KEY = 'temper_rejects_payload' const CACHE_KEY = 'temper_rejects_payload'
@ -18,9 +23,12 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const usingSample = ref(false) const usingSample = ref(false)
const disconnected = ref(false)
const lastUpdated = ref<number | null>(null) const lastUpdated = ref<number | null>(null)
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setTimeout> | null = null
let pollMs = 20000
const RETRY_MS = 5000 // faster cadence while disconnected, to recover quickly
let filterInitialized = false let filterInitialized = false
const hasData = computed(() => payload.value !== null) const hasData = computed(() => payload.value !== null)
@ -76,13 +84,16 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
} }
payload.value = res.data payload.value = res.data
usingSample.value = res.data.sample === true usingSample.value = res.data.sample === true
disconnected.value = false
} catch (e) { } catch (e) {
// Connection lost. Keep the last known REAL data (stale > fabricated): never
// replace live carts with demo carts on a live terminal, or an operator could
// book against a fabricated entry. Flag `disconnected` so the UI shows a
// reconnect spinner (cold start) or a header indicator (stale data) and retries.
disconnected.value = true
error.value = e instanceof Error ? e.message : 'Overview API unreachable' error.value = e instanceof Error ? e.message : 'Overview API unreachable'
// Keep the last known REAL data on a transient failure (stale > fabricated): never // Sample data is for `npm run dev` only (no gateway) — `vite build` strips this.
// replace live carts with bundled demo carts on a live terminal, or an operator if (import.meta.env.DEV && (!payload.value || usingSample.value)) {
// could book against a fabricated entry. Only fall back to sample on a COLD start
// with nothing to show (dev standalone, where there is no gateway).
if (!payload.value || usingSample.value) {
payload.value = sampleStation(window.value) payload.value = sampleStation(window.value)
usingSample.value = true usingSample.value = true
} }
@ -102,9 +113,15 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
async function fetchReasons() { async function fetchReasons() {
try { try {
const res = await api.get<{ reasons: Reason[] }>('/reasons') const res = await api.get<{ reasons: Reason[] }>('/reasons')
reasons.value = Array.isArray(res.data?.reasons) ? res.data.reasons : SAMPLE_REASONS if (Array.isArray(res.data?.reasons) && res.data.reasons.length) {
reasons.value = res.data.reasons
return
}
throw new Error('no reasons')
} catch { } catch {
reasons.value = SAMPLE_REASONS // Keep any reasons already loaded; only seed the bundled sample reasons under
// `npm run dev` — production must never offer fabricated reason codes.
if (import.meta.env.DEV && !reasons.value.length) reasons.value = SAMPLE_REASONS
} }
} }
@ -152,13 +169,22 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
} }
} }
// Self-scheduling poll: next tick is the normal cadence when connected, a faster
// retry while disconnected so the terminal recovers as soon as the gateway is back.
function scheduleNext() {
timer = setTimeout(async () => {
await refresh()
scheduleNext()
}, disconnected.value ? RETRY_MS : pollMs)
}
function startPolling(ms = 20000) { function startPolling(ms = 20000) {
if (timer) return if (timer) return
timer = setInterval(refresh, ms) pollMs = ms
scheduleNext()
} }
function stopPolling() { function stopPolling() {
if (timer) { if (timer) {
clearInterval(timer) clearTimeout(timer)
timer = null timer = null
} }
} }
@ -173,6 +199,7 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
loading, loading,
error, error,
usingSample, usingSample,
disconnected,
lastUpdated, lastUpdated,
hasData, hasData,
allPresses, allPresses,

View File

@ -114,6 +114,27 @@ html:not(.dark) body {
background-position: 11px 0; background-position: 11px 0;
} }
} }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Reconnect spinner (connection-loss states). `.sm` is the inline header variant. */
.spinner {
display: inline-block;
width: 30px;
height: 30px;
border: 3px solid var(--line-2);
border-top-color: var(--transit);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner.sm {
width: 13px;
height: 13px;
border-width: 2px;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { * {

View File

@ -5,8 +5,10 @@
5-station temper belt and an "Ausschuss buchen" action booking popover 5-station temper belt and an "Ausschuss buchen" action booking popover
(a tooltip-style panel anchored to the button, not a centered modal). (a tooltip-style panel anchored to the button, not a centered modal).
Login-free by design. Data from the store (polled); no-backend falls back to Login-free by design. Data from the store (polled). On connection loss the terminal
bundled sample data so `npm run dev` renders + the booking flow is demoable. keeps the last real carts (or shows a reconnect spinner on a cold start) and retries
it never shows demo carts in production. Sample data renders only under
`npm run dev` (no gateway), where the booking flow is demoable.
--> -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
@ -21,7 +23,7 @@ import type { RejectEntry, RejectRequest } from '@/types/station'
const { t } = useI18n() const { t } = useI18n()
const store = useTemperRejectsStore() const store = useTemperRejectsStore()
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample } = storeToRefs(store) const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample, hasData, disconnected } = storeToRefs(store)
const pop = ref<InstanceType<typeof RejectPopover> | null>(null) const pop = ref<InstanceType<typeof RejectPopover> | null>(null)
const bookingEntry = ref<RejectEntry | null>(null) const bookingEntry = ref<RejectEntry | null>(null)
@ -80,9 +82,15 @@ onUnmounted(() => {
<template> <template>
<div class="wrap"> <div class="wrap">
<StationHeader :window="window" :sample="usingSample" @update:window="store.setWindow" /> <StationHeader :window="window" :sample="usingSample" :disconnected="disconnected" @update:window="store.setWindow" />
<div class="layout"> <!-- Cold start / no data yet: never blank, never fake spinner that keeps retrying. -->
<div v-if="!hasData" class="state connect">
<span class="spinner" aria-hidden="true"></span>
<span class="msg">{{ disconnected ? t('conn.lost') : t('conn.connecting') }}</span>
</div>
<div v-else class="layout">
<PressFilter <PressFilter
:presses="allPresses" :presses="allPresses"
:checked="checkedPresses" :checked="checkedPresses"
@ -140,6 +148,18 @@ onUnmounted(() => {
border: 1px dashed var(--line-2); border: 1px dashed var(--line-2);
border-radius: var(--r); border-radius: var(--r);
} }
/* Connection spinner state (cold start / reconnecting): full-width, no dashed box. */
.state.connect {
flex-direction: column;
gap: 16px;
min-height: 60vh;
margin-top: 14px;
border: 0;
}
.state.connect .msg {
font-size: 0.85rem;
letter-spacing: 0.04em;
}
.toast { .toast {
position: fixed; position: fixed;
left: 50%; left: 50%;