Compare commits

..

No commits in common. "173fdaaf63698cdbcacba73b32296f16f6beed5b" and "cc8a76cdef8efb0c57015426b535311d715f9b56" have entirely different histories.

13 changed files with 73 additions and 164 deletions

4
_db.py
View File

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

View File

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

1
dist/assets/index-B4JBFZH3.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>Temperprozess · Live-Übersicht</title> <title>Temperprozess · Live-Übersicht</title>
<script type="module" crossorigin src="/module/temper_overview/assets/index-DUpp_SaJ.js"></script> <script type="module" crossorigin src="/module/temper_overview/assets/index-Vt9ocbug.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-CmR_19mg.css"> <link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-B4JBFZH3.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 { RangeCode } from '@/types/overview' import type { RangeCode } from '@/types/overview'
defineProps<{ plant: string; range: RangeCode; sample: boolean; disconnected?: boolean }>() defineProps<{ plant: string; range: RangeCode; sample: boolean }>()
const emit = defineEmits<{ (e: 'update:range', code: RangeCode): void }>() const emit = defineEmits<{ (e: 'update:range', code: RangeCode): void }>()
const { t } = useI18n() const { t } = useI18n()
@ -29,8 +29,7 @@ onUnmounted(() => {
<span class="title">{{ plant }} · <b>{{ t('app.line') }}</b> · {{ t('app.subtitle') }}</span> <span class="title">{{ plant }} · <b>{{ t('app.line') }}</b> · {{ t('app.subtitle') }}</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 v-if="disconnected" class="reconnect"><span class="spinner sm" aria-hidden="true"></span>{{ t('conn.reconnecting') }}</span> <span class="live"><span class="led"></span>{{ t('app.live') }}</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('range.label')"> <span class="rangep" role="group" :aria-label="t('range.label')">
<span class="rl">{{ t('range.label') }}</span> <span class="rl">{{ t('range.label') }}</span>
@ -106,16 +105,6 @@ onUnmounted(() => {
background: var(--run); background: var(--run);
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

@ -29,57 +29,51 @@ function chips(...keys: string[]): OrderChip[] {
return keys.map((k) => ({ orderNo: ORDERS[k][0], colorIndex: ORDERS[k][1] })) return keys.map((k) => ({ orderNo: ORDERS[k][0], colorIndex: ORDERS[k][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 },
{ name: 'P4', status: 'stop', orders: chips('o2'), good: 430, cycleTime: null, notVerladen: 0, note: 'Werkzeug rüsten', placeholder: false },
{ name: 'P5', status: 'run', orders: chips('o3'), good: 1975, cycleTime: '2.8', notVerladen: 41, note: null, placeholder: false },
{ name: 'P6', status: 'run', orders: chips('o2'), good: 744, cycleTime: '6.0', notVerladen: 23, note: null, placeholder: false },
{ name: 'P7', status: 'idle', orders: [], good: 0, cycleTime: null, notVerladen: 0, note: 'Kein Auftrag', placeholder: false },
{ 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[]> = {
P3: [{ cartId: 'C-014a', orders: portions(['o1', 54], ['o2', 42]), etaMin: 3 }],
P5: [{ cartId: 'C-014b', orders: portions(['o3', 60]), etaMin: 7 }],
P8: [
{ cartId: 'C-015', orders: portions(['o1', 30], ['o4', 30], ['o2', 30], ['o3', 30]), etaMin: 9 },
{ cartId: 'C-019', orders: portions(['o4', 36]), etaMin: 14 },
{ cartId: 'C-020', orders: portions(['o1', 28]), etaMin: 19 },
],
}
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_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_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). const FINISHED_LIST = [
* { cartId: 'C-010', orders: portions(['o2', 50], ['o3', 40]), ago: 'vor 8 min' },
* All cart/press data is built INSIDE this function on purpose: it keeps the module { cartId: 'C-009', orders: portions(['o1', 64]), ago: 'vor 21 min' },
* free of top-level side-effecting calls (`chips()`/`portions()`), so when this { cartId: 'C-008', orders: portions(['o4', 58]), ago: 'vor 35 min' },
* function is unused every production build, where the sample fallback is gated { cartId: 'C-007', orders: portions(['o2', 44], ['o1', 20]), ago: 'vor 52 min' },
* behind `import.meta.env.DEV` Rollup tree-shakes the whole file out of the bundle. */ { 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 { export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
const f = RANGE_FACTORS[range] ?? 1 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 },
{ name: 'P4', status: 'stop', orders: chips('o2'), good: 430, cycleTime: null, notVerladen: 0, note: 'Werkzeug rüsten', placeholder: false },
{ name: 'P5', status: 'run', orders: chips('o3'), good: 1975, cycleTime: '2.8', notVerladen: 41, note: null, placeholder: false },
{ name: 'P6', status: 'run', orders: chips('o2'), good: 744, cycleTime: '6.0', notVerladen: 23, note: null, placeholder: false },
{ name: 'P7', status: 'idle', orders: [], good: 0, cycleTime: null, notVerladen: 0, note: 'Kein Auftrag', placeholder: false },
{ 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[]> = {
P3: [{ cartId: 'C-014a', orders: portions(['o1', 54], ['o2', 42]), etaMin: 3 }],
P5: [{ cartId: 'C-014b', orders: portions(['o3', 60]), etaMin: 7 }],
P8: [
{ cartId: 'C-015', orders: portions(['o1', 30], ['o4', 30], ['o2', 30], ['o3', 30]), etaMin: 9 },
{ cartId: 'C-019', orders: portions(['o4', 36]), etaMin: 14 },
{ cartId: 'C-020', orders: portions(['o1', 28]), etaMin: 19 },
],
}
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_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' },
]
return { return {
plant: 'Werk 1', plant: 'Werk 1',
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),

View File

@ -7,11 +7,6 @@ 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 …',
},
flow: { flow: {
presses: 'Pressen', presses: 'Pressen',
pressesSub: 'inkl. nicht verladen', pressesSub: 'inkl. nicht verladen',

View File

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

View File

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

View File

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

View File

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