Merge pull request 'fix/spa-reconnect-no-demo-data' (#1) from fix/spa-reconnect-no-demo-data into main

Reviewed-on: #1
This commit is contained in:
Erik 2026-07-09 10:56:18 +00:00
commit 173fdaaf63
13 changed files with 164 additions and 73 deletions

4
_db.py
View File

@ -16,6 +16,6 @@ real connection opens only when a query actually executes.
"""
from __future__ import annotations
from app.db.mariadb import run_select_query # noqa: F401 (kernel SELECT-only proxy)
from app.db.mariadb import run_select_query, cached_select # noqa: F401 (kernel read proxies)
__all__ = ["run_select_query"]
__all__ = ["run_select_query", "cached_select"]

View File

@ -46,7 +46,7 @@ from typing import Any, Optional
from fastapi import APIRouter, Query
from .._db import run_select_query
from .._db import run_select_query, cached_select
logger = logging.getLogger(__name__)
@ -128,8 +128,11 @@ def _group_carts(rows: list[dict[str, Any]], colors: dict[str, int]) -> "dict[st
def _fetch_presses(plant: str) -> list[str]:
# Reference data (admin-edited), re-read on every 12s poll — served from the shared
# reference cache so it stops consuming a pool checkout each poll.
try:
rows = run_select_query(
rows = cached_select(
f"tov:presses:{plant}",
"SELECT psPress AS press FROM press_settings.presses WHERE psPlant = :plant "
"ORDER BY CAST(psPress AS UNSIGNED), psPress",
{"plant": plant},
@ -239,8 +242,10 @@ def _fetch_downtime_reasons(plant: str) -> dict[str, int]:
def _fetch_reason_texts() -> dict[int, str]:
# Reference data (admin-edited), full-table read on every poll — served from cache.
try:
rows = run_select_query(
rows = cached_select(
"tov:reason_texts",
"SELECT id, reason_DE AS de FROM settings.downtime_reasons", {}
)
except Exception as e: # noqa: BLE001

File diff suppressed because one or more lines are too long

1
dist/assets/index-CmR_19mg.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

4
dist/index.html vendored
View File

@ -9,8 +9,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Temperprozess · Live-Übersicht</title>
<script type="module" crossorigin src="/module/temper_overview/assets/index-Vt9ocbug.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-B4JBFZH3.css">
<script type="module" crossorigin src="/module/temper_overview/assets/index-DUpp_SaJ.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-CmR_19mg.css">
</head>
<body>
<div id="app"></div>

View File

@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RangeCode } from '@/types/overview'
defineProps<{ plant: string; range: RangeCode; sample: boolean }>()
defineProps<{ plant: string; range: RangeCode; sample: boolean; disconnected?: boolean }>()
const emit = defineEmits<{ (e: 'update:range', code: RangeCode): void }>()
const { t } = useI18n()
@ -29,7 +29,8 @@ onUnmounted(() => {
<span class="title">{{ plant }} · <b>{{ t('app.line') }}</b> · {{ t('app.subtitle') }}</span>
<span v-if="sample" class="demo">{{ t('app.demo') }}</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="rangep" role="group" :aria-label="t('range.label')">
<span class="rl">{{ t('range.label') }}</span>
@ -105,6 +106,16 @@ onUnmounted(() => {
background: var(--run);
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 {
font-size: 0.95rem;
color: var(--ink-dim);

View File

@ -29,7 +29,19 @@ function chips(...keys: string[]): OrderChip[] {
return keys.map((k) => ({ orderNo: ORDERS[k][0], colorIndex: ORDERS[k][1] }))
}
const PRESSES: PressInfo[] = [
const FINISHED_BASE = { carts: 47, parts: 5120, scrap: 63, avgOvenMin: 118 }
const FINISHED_BY_ORDER: Record<string, number> = { o1: 1680, o2: 1290, o3: 1150, o4: 1000 }
/** Build the sample payload for a range (finished figures scale; live stats don't).
*
* All cart/press data is built INSIDE this function on purpose: it keeps the module
* free of top-level side-effecting calls (`chips()`/`portions()`), so when this
* function is unused every production build, where the sample fallback is gated
* behind `import.meta.env.DEV` Rollup tree-shakes the whole file out of the bundle. */
export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
const f = RANGE_FACTORS[range] ?? 1
const PRESSES: PressInfo[] = [
{ name: 'P1', status: 'run', orders: chips('o1'), good: 1240, cycleTime: '4.2', notVerladen: 34, note: null, placeholder: false },
{ name: 'P2', status: 'run', orders: chips('o2'), good: 880, cycleTime: '6.1', notVerladen: 18, note: null, placeholder: false },
{ name: 'P3', status: 'run', orders: chips('o1', 'o4'), good: 612, cycleTime: '4.3', notVerladen: 52, note: null, placeholder: false },
@ -40,9 +52,9 @@ const PRESSES: PressInfo[] = [
{ name: 'P8', status: 'run', orders: chips('o4'), good: 1102, cycleTime: '3.9', notVerladen: 29, note: null, placeholder: false },
{ name: 'P9', status: 'run', orders: chips('o1'), good: 968, cycleTime: '4.1', notVerladen: 12, note: null, placeholder: false },
{ name: 'P10', status: 'idle', orders: [], good: 0, cycleTime: null, notVerladen: 0, note: null, placeholder: true },
]
]
const ON_WAY: Record<string, CartOnWay[]> = {
const ON_WAY: Record<string, CartOnWay[]> = {
P3: [{ cartId: 'C-014a', orders: portions(['o1', 54], ['o2', 42]), etaMin: 3 }],
P5: [{ cartId: 'C-014b', orders: portions(['o3', 60]), etaMin: 7 }],
P8: [
@ -50,30 +62,24 @@ const ON_WAY: Record<string, CartOnWay[]> = {
{ cartId: 'C-019', orders: portions(['o4', 36]), etaMin: 14 },
{ cartId: 'C-020', orders: portions(['o1', 28]), etaMin: 19 },
],
}
}
const OVEN: OvenBatch[] = [
const OVEN: OvenBatch[] = [
{ cartId: 'C-011', orders: portions(['o1', 88]), totalMin: 120, remainingMin: 42 },
{ cartId: 'C-012', orders: portions(['o2', 60], ['o4', 44]), totalMin: 120, remainingMin: 71 },
{ cartId: 'C-013', orders: portions(['o3', 72]), totalMin: 120, remainingMin: 12 },
{ cartId: 'C-016', orders: portions(['o1', 40], ['o2', 36]), totalMin: 120, remainingMin: 98 },
{ cartId: 'C-017', orders: portions(['o4', 80]), totalMin: 120, remainingMin: 104 },
]
]
const FINISHED_BASE = { carts: 47, parts: 5120, scrap: 63, avgOvenMin: 118 }
const FINISHED_BY_ORDER: Record<string, number> = { o1: 1680, o2: 1290, o3: 1150, o4: 1000 }
const FINISHED_LIST = [
const FINISHED_LIST = [
{ cartId: 'C-010', orders: portions(['o2', 50], ['o3', 40]), ago: 'vor 8 min' },
{ cartId: 'C-009', orders: portions(['o1', 64]), ago: 'vor 21 min' },
{ cartId: 'C-008', orders: portions(['o4', 58]), ago: 'vor 35 min' },
{ cartId: 'C-007', orders: portions(['o2', 44], ['o1', 20]), ago: 'vor 52 min' },
{ cartId: 'C-006', orders: portions(['o3', 72]), ago: 'vor 1 h 6 min' },
]
]
/** Build the sample payload for a range (finished figures scale; live stats don't). */
export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
const f = RANGE_FACTORS[range] ?? 1
return {
plant: 'Werk 1',
generatedAt: new Date().toISOString(),

View File

@ -7,6 +7,11 @@ export default {
live: 'Live',
demo: 'Demo-Daten',
},
conn: {
connecting: 'Verbinde mit Server …',
lost: 'Verbindung verloren neuer Versuch …',
reconnecting: 'Verbinde neu …',
},
flow: {
presses: 'Pressen',
pressesSub: 'inkl. nicht verladen',

View File

@ -8,6 +8,11 @@ export default {
live: 'Live',
demo: 'Sample data',
},
conn: {
connecting: 'Connecting to server …',
lost: 'Connection lost retrying …',
reconnecting: 'Reconnecting …',
},
flow: {
presses: 'Presses',
pressesSub: 'incl. not yet loaded',

View File

@ -8,8 +8,11 @@ import type { OverviewPayload, RangeCode } from '@/types/overview'
// start → refresh() background fetch → startPolling(ms). sessionStorage caches the
// last payload so a reload paints instantly with stale data while fresh loads.
//
// Fallback: if the data API is unreachable (e.g. `npm run dev` with no gateway),
// the store falls back to the bundled sample payload so the design still renders.
// Connection loss: NEVER fabricate live data. On a failed fetch the store keeps the
// last known REAL payload (stale > fake) and flips `disconnected`, so the UI shows a
// reconnect spinner and polling retries faster until the gateway answers again. The
// bundled sample payload is ONLY used under `npm run dev` (no gateway at all) — gated
// behind `import.meta.env.DEV`, so `vite build` tree-shakes it out of production.
const CACHE_KEY = 'temper_overview_payload'
@ -19,9 +22,12 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
const loading = ref(false)
const error = ref<string | null>(null)
const usingSample = ref(false)
const disconnected = ref(false)
const lastUpdated = ref<number | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let pollMs = 12000
const RETRY_MS = 4000 // faster cadence while disconnected, to recover quickly
const hasData = computed(() => payload.value !== null)
@ -51,7 +57,7 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
await refresh()
}
/** Background refresh. Keeps prior data on error; falls back to sample data. */
/** Background refresh. Keeps prior data on error and flags `disconnected`. */
async function refresh() {
error.value = null
try {
@ -59,21 +65,28 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
// Guard the shape: a dev server (vite dev/preview) SPA-fallbacks unknown
// routes to index.html (HTTP 200 HTML), so a missing backend looks like a
// "successful" non-JSON response. Treat anything without a presses[] array
// as a miss → fall through to the bundled sample.
// as a miss → the catch below handles it as a connection failure.
if (!res.data || !Array.isArray(res.data.presses)) {
throw new Error('Unexpected /overview response (no backend?)')
}
payload.value = res.data
usingSample.value = res.data.sample === true
disconnected.value = false
lastUpdated.value = Date.now()
saveCache()
} catch (e) {
// No backend (dev) or API down → render the bundled sample so the board is
// never blank. Real deployments hit the gateway API and never land here.
// Connection lost or bad response. Do NOT overwrite live data with fabricated
// demo data on a real deployment — keep the last known payload (stale) and flag
// `disconnected` so the board shows a reconnect spinner and keeps retrying.
disconnected.value = true
error.value = e instanceof Error ? e.message : 'Overview API unreachable'
// Only when running `npm run dev` (no gateway at all) do we render the bundled
// sample so the design is still viewable. `vite build` strips this branch.
if (import.meta.env.DEV) {
payload.value = sampleOverview(range.value)
usingSample.value = true
lastUpdated.value = Date.now()
error.value = e instanceof Error ? e.message : 'Overview API unreachable — showing sample data'
}
} finally {
loading.value = false
}
@ -85,14 +98,25 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
await refresh()
}
// Self-scheduling poll: after each refresh, the next tick is picked from the
// current connection state — the normal cadence when connected, a faster retry
// while disconnected so the board recovers as soon as the gateway is back.
function scheduleNext() {
timer = setTimeout(async () => {
await refresh()
scheduleNext()
}, disconnected.value ? RETRY_MS : pollMs)
}
function startPolling(ms = 12000) {
if (timer) return
timer = setInterval(refresh, ms)
pollMs = ms
scheduleNext()
}
function stopPolling() {
if (timer) {
clearInterval(timer)
clearTimeout(timer)
timer = null
}
}
@ -105,6 +129,7 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
loading,
error,
usingSample,
disconnected,
lastUpdated,
hasData,
hydrate,

View File

@ -140,6 +140,27 @@ html:not(.dark) body {
opacity: 1;
}
}
@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;
}
/* Reduced motion: disable the conveyor / flame animations (accessibility). */
@media (prefers-reduced-motion: reduce) {

View File

@ -5,8 +5,10 @@
hang under their origin press; the oven & finished bands scroll horizontally.
The time-range picker scopes ONLY the Fertig figures.
Data comes from the store (GET /api/overview, polled). With no backend the
store falls back to bundled sample data, so `npm run dev` renders the design.
Data comes from the store (GET /api/overview, polled). On connection loss the
board keeps the last real data (or shows a reconnect spinner on a cold start) and
retries it never shows demo data in production. Sample data renders only under
`npm run dev` (no gateway).
-->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
@ -20,7 +22,7 @@ import { useTemperOverviewStore } from '@/stores/temperOverview'
const { t } = useI18n()
const store = useTemperOverviewStore()
const { payload, range, loading, hasData, usingSample } = storeToRefs(store)
const { payload, range, hasData, usingSample, disconnected } = storeToRefs(store)
onMounted(async () => {
await store.hydrate()
@ -36,6 +38,7 @@ onUnmounted(() => store.stopPolling())
:plant="payload.plant"
:range="range"
:sample="usingSample"
:disconnected="disconnected"
@update:range="store.setRange"
/>
<FlowLegend />
@ -43,7 +46,11 @@ onUnmounted(() => store.stopPolling())
<ProductionLine :payload="payload" />
</template>
<div v-else-if="loading" class="state">{{ t('app.live') }} </div>
<!-- Cold start / no data yet: never blank, never fake spinner that keeps retrying. -->
<div v-else class="state">
<span class="spinner" aria-hidden="true"></span>
<span class="msg">{{ disconnected ? t('conn.lost') : t('conn.connecting') }}</span>
</div>
</div>
</template>
@ -55,10 +62,16 @@ onUnmounted(() => store.stopPolling())
}
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
min-height: 60vh;
color: var(--ink-dim);
font-family: var(--mono);
}
.state .msg {
font-size: 0.85rem;
letter-spacing: 0.04em;
}
</style>