Compare commits
3 Commits
cc8a76cdef
...
173fdaaf63
| Author | SHA1 | Date | |
|---|---|---|---|
| 173fdaaf63 | |||
| 3d52e0b79e | |||
| 360b049af9 |
4
_db.py
4
_db.py
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
1
dist/assets/index-B4JBFZH3.css
vendored
1
dist/assets/index-B4JBFZH3.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-CmR_19mg.css
vendored
Normal file
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
4
dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user