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 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 .._db import run_select_query, cached_select
from .._db import run_select_query
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]:
# 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 = cached_select(
f"tov:presses:{plant}",
rows = run_select_query(
"SELECT psPress AS press FROM press_settings.presses WHERE psPlant = :plant "
"ORDER BY CAST(psPress AS UNSIGNED), psPress",
{"plant": plant},
@ -242,10 +239,8 @@ 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 = cached_select(
"tov:reason_texts",
rows = run_select_query(
"SELECT id, reason_DE AS de FROM settings.downtime_reasons", {}
)
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 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-DUpp_SaJ.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-CmR_19mg.css">
<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">
</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; disconnected?: boolean }>()
defineProps<{ plant: string; range: RangeCode; sample: boolean }>()
const emit = defineEmits<{ (e: 'update:range', code: RangeCode): void }>()
const { t } = useI18n()
@ -29,8 +29,7 @@ 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 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="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>
@ -106,16 +105,6 @@ 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,19 +29,7 @@ function chips(...keys: string[]): OrderChip[] {
return keys.map((k) => ({ orderNo: ORDERS[k][0], colorIndex: ORDERS[k][1] }))
}
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[] = [
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 },
@ -52,9 +40,9 @@ export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
{ 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: [
@ -62,24 +50,30 @@ export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
{ 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_LIST = [
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 = [
{ 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,11 +7,6 @@ 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,11 +8,6 @@ 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,11 +8,8 @@ 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.
//
// 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.
// 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.
const CACHE_KEY = 'temper_overview_payload'
@ -22,12 +19,9 @@ 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 setTimeout> | null = null
let pollMs = 12000
const RETRY_MS = 4000 // faster cadence while disconnected, to recover quickly
let timer: ReturnType<typeof setInterval> | null = null
const hasData = computed(() => payload.value !== null)
@ -57,7 +51,7 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
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() {
error.value = null
try {
@ -65,28 +59,21 @@ 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 → 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)) {
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) {
// 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) {
// 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.
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
}
@ -98,25 +85,14 @@ 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
pollMs = ms
scheduleNext()
timer = setInterval(refresh, ms)
}
function stopPolling() {
if (timer) {
clearTimeout(timer)
clearInterval(timer)
timer = null
}
}
@ -129,7 +105,6 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
loading,
error,
usingSample,
disconnected,
lastUpdated,
hasData,
hydrate,

View File

@ -140,27 +140,6 @@ 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,10 +5,8 @@
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). 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).
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.
-->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
@ -22,7 +20,7 @@ import { useTemperOverviewStore } from '@/stores/temperOverview'
const { t } = useI18n()
const store = useTemperOverviewStore()
const { payload, range, hasData, usingSample, disconnected } = storeToRefs(store)
const { payload, range, loading, hasData, usingSample } = storeToRefs(store)
onMounted(async () => {
await store.hydrate()
@ -38,7 +36,6 @@ onUnmounted(() => store.stopPolling())
:plant="payload.plant"
:range="range"
:sample="usingSample"
:disconnected="disconnected"
@update:range="store.setRange"
/>
<FlowLegend />
@ -46,11 +43,7 @@ onUnmounted(() => store.stopPolling())
<ProductionLine :payload="payload" />
</template>
<!-- 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 v-else-if="loading" class="state">{{ t('app.live') }} </div>
</div>
</template>
@ -62,16 +55,10 @@ 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>