Merge branch 'fix/spa-reconnect-no-demo-data' into main

Brings press-number badges, the header rework (Temperprozess pill,
Live-Uebersicht in blue), and the fused header+legend top panel onto main.
This commit is contained in:
Erik 2026-07-10 16:32:52 +02:00
commit 28b648d335
20 changed files with 271 additions and 63 deletions

View File

@ -91,11 +91,11 @@ figures — presses / on-the-way / oven are always real-time.
"ovenTempC": 185, "ovenAvgMin": 120, "sample": true,
"presses": [{ "name":"P1","status":"run","orders":[{"orderNo":"O-4471","colorIndex":0}],
"good":1240,"cycleTime":"4.2","notVerladen":34,"note":null,"placeholder":false }],
"onWay": { "P3":[{ "cartId":"C-014a","orders":[{"orderNo":"O-4471","qty":54,"colorIndex":0}],"etaMin":3 }] },
"oven": [{ "cartId":"C-011","orders":[],"totalMin":120,"remainingMin":42 }],
"onWay": { "P3":[{ "cartId":"C-014a","press":"P3","orders":[{"orderNo":"O-4471","qty":54,"colorIndex":0}],"etaMin":3 }] },
"oven": [{ "cartId":"C-011","press":"P1","orders":[],"totalMin":120,"remainingMin":42 }],
"finished": { "carts":47,"parts":5120,"scrap":63,"avgOvenMin":118,
"byOrder":[{"orderNo":"O-4471","colorIndex":0,"parts":1680}] },
"finishedList": [{ "cartId":"C-010","orders":[],"ago":"vor 8 min" }]
"byOrder":[{"orderNo":"O-4471","colorIndex":0,"parts":1680,"press":"P1"}] },
"finishedList": [{ "cartId":"C-010","press":"P6","orders":[],"ago":"vor 8 min" }]
}
```

View File

@ -31,6 +31,14 @@ MODULE_NAME = "temper_overview"
# MUST match vite.config.ts `base` (with a trailing slash on the Vite side).
MODULE_PREFIX = "/module/temper_overview"
# CORS opt-in (read by the kernel loader, app.core.modules). This login-free board
# is embedded cross-origin in a Grafana text panel, which renders it inside a
# SANDBOXED iframe → the document gets an opaque origin (Origin: null). Vite tags the
# SPA's own JS/CSS `crossorigin`, so those assets are fetched in CORS mode and the
# browser blocks them (blank page) unless the gateway allows the read. "*" is safe:
# this is no-auth, read-only shop-floor data, and only "*" satisfies Origin: null.
CORS_ALLOW_ORIGINS = ["*"]
def register(app: FastAPI) -> None:
"""Mount the data API and the SPA onto the gateway app.

View File

@ -82,6 +82,16 @@ def _range_code(raw: str) -> str:
return raw if raw in _RANGE_MINUTES else _DEFAULT_RANGE
def _abs_window_label(from_ms: int, to_ms: int) -> str:
"""Compact human label for an absolute (Grafana) finished-figures window, shown
where the preset code used to be e.g. '26 h', '3 d'. Purely cosmetic."""
minutes = max(0, round((to_ms - from_ms) / 60000))
if minutes < 90:
return f"{minutes} min"
hours = minutes / 60
return f"{round(hours)} h" if hours < 48 else f"{round(hours / 24)} d"
def _color_map(*ordernumbers: str) -> dict[str, int]:
"""Assign each distinct order number a colour INDEX 0..3 (the frontend maps the
index hex via ``src/data/orderColors.ts``). Sorted so the mapping is stable for
@ -124,6 +134,18 @@ def _group_carts(rows: list[dict[str, Any]], colors: dict[str, int]) -> "dict[st
return carts
def _cart_press(rows: list[dict[str, Any]]) -> str:
"""The origin press label for a grouped cart. Normally one press per cart; if a
cart carries rows from several presses, join the distinct ones (first-seen order)
with '+' (e.g. 'P3+P5'), so the badge on the board never hides a mixed origin."""
presses: list[str] = []
for r in rows:
p = str(r["press"])
if p and p not in presses:
presses.append(p)
return "+".join(presses)
# ── SQL fetches (each degrades to empty on failure so the board never 500s) ─────
@ -308,9 +330,33 @@ def _fetch_active_carts(plant: str) -> list[dict[str, Any]]:
]
def _fetch_finished_carts(plant: str, window_min: int) -> list[dict[str, Any]]:
def _fetch_finished_carts(
plant: str,
*,
window_min: Optional[int] = None,
from_s: Optional[float] = None,
to_s: Optional[float] = None,
) -> list[dict[str, Any]]:
"""Carts out of the oven within the selected window — one row per (cart,order),
newest first. Excludes reprints."""
newest first. Excludes reprints.
The window is EITHER a preset look-back (``window_min`` minutes back from NOW())
OR an absolute range from Grafana (``from_s``/``to_s``, unix seconds). Absolute
bounds use ``FROM_UNIXTIME`` which resolves in the SAME DB session clock as
``NOW()``, so the timezone handling stays identical to the preset path."""
if from_s is not None and to_s is not None:
bound_sql = (
" AND NOW() >= ttOutOfOvenAt "
" AND ttOutOfOvenAt >= FROM_UNIXTIME(:from_s) "
" AND ttOutOfOvenAt <= FROM_UNIXTIME(:to_s) "
)
binds: dict[str, Any] = {"plant": plant, "from_s": from_s, "to_s": to_s}
else:
bound_sql = (
" AND NOW() >= ttOutOfOvenAt "
" AND ttOutOfOvenAt >= NOW() - INTERVAL :window_min MINUTE "
)
binds = {"plant": plant, "window_min": window_min}
try:
rows = run_select_query(
"SELECT ttCartId AS cartId, ttId, ttPress AS press, ttOrdernumber AS orderno, "
@ -319,10 +365,10 @@ def _fetch_finished_carts(plant: str, window_min: int) -> list[dict[str, Any]]:
"TIMESTAMPDIFF(MINUTE, ttOutOfOvenAt, NOW()) AS agoMin "
"FROM production.temper_tracking "
"WHERE ttPlant = :plant AND ttReprintOf IS NULL "
" AND ttOutOfOvenAt IS NOT NULL AND NOW() >= ttOutOfOvenAt "
" AND ttOutOfOvenAt >= NOW() - INTERVAL :window_min MINUTE "
"ORDER BY ttOutOfOvenAt DESC, ttId DESC",
{"plant": plant, "window_min": window_min},
" AND ttOutOfOvenAt IS NOT NULL "
+ bound_sql
+ "ORDER BY ttOutOfOvenAt DESC, ttId DESC",
binds,
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: finished carts unavailable: %s", e)
@ -427,6 +473,7 @@ def _build_payload(
on_way[press] = [
{
"cartId": cart["cartId"],
"press": _cart_press(cart["_rows"]),
"orders": cart["orders"],
"etaMin": min(r["etaMin"] for r in cart["_rows"]),
}
@ -443,6 +490,7 @@ def _build_payload(
oven.append(
{
"cartId": cart["cartId"],
"press": _cart_press(rows),
"orders": cart["orders"],
"totalMin": total_min,
"remainingMin": min(r["remainingMin"] for r in rows),
@ -457,10 +505,15 @@ def _build_payload(
finished_avg = round(sum(temper_mins) / len(temper_mins)) if temper_mins else DEFAULT_OVEN_MINUTES
by_order_parts: dict[str, int] = {}
by_order_press: dict[str, list[str]] = {}
for c in finished_carts:
by_order_parts[c["orderno"]] = by_order_parts.get(c["orderno"], 0) + c["qty"]
o = c["orderno"]
by_order_parts[o] = by_order_parts.get(o, 0) + c["qty"]
seen = by_order_press.setdefault(o, [])
if c["press"] and c["press"] not in seen: # distinct presses, first-seen order
seen.append(c["press"])
by_order = [
{"orderNo": o, "colorIndex": colors.get(o, 0), "parts": p}
{"orderNo": o, "colorIndex": colors.get(o, 0), "parts": p, "press": "+".join(by_order_press.get(o, []))}
for o, p in sorted(by_order_parts.items(), key=lambda kv: kv[1], reverse=True)
]
@ -475,6 +528,7 @@ def _build_payload(
finished_list = [
{
"cartId": cart["cartId"],
"press": _cart_press(cart["_rows"]),
"orders": cart["orders"],
"ago": _fmt_ago(min((r["agoMin"] for r in cart["_rows"] if r["agoMin"] is not None), default=None)),
}
@ -502,11 +556,31 @@ def _build_payload(
@router.get("/overview", summary="Whole temper-line overview (live data)")
def overview(range: str = Query(_DEFAULT_RANGE, description="finished-figures window: 12h|1d|7d|14d|30d")) -> dict[str, Any]:
code = _range_code(range)
window_min = _RANGE_MINUTES[code]
def overview(
range: str = Query(_DEFAULT_RANGE, description="finished-figures window: 12h|1d|7d|14d|30d"),
from_ms: Optional[int] = Query(
None, alias="from",
description="absolute finished-figures window START, epoch ms (Grafana ${__from}). "
"When paired with `to`, overrides `range` with an exact from→to window.",
),
to_ms: Optional[int] = Query(
None, alias="to",
description="absolute finished-figures window END, epoch ms (Grafana ${__to}).",
),
) -> dict[str, Any]:
plant = PLANT_NAME
# Grafana embed passes an absolute [from,to] window (epoch ms); it wins over the
# preset `range`. Otherwise fall back to the preset look-back the in-page picker uses.
absolute = from_ms is not None and to_ms is not None and to_ms > from_ms
if absolute:
finished_carts = _fetch_finished_carts(plant, from_s=from_ms / 1000.0, to_s=to_ms / 1000.0)
range_label = _abs_window_label(from_ms, to_ms)
else:
code = _range_code(range)
finished_carts = _fetch_finished_carts(plant, window_min=_RANGE_MINUTES[code])
range_label = code
presses = _fetch_presses(plant)
active_orders = _fetch_active_orders(plant)
rejects = _fetch_rejects([o["orderno"] for o in active_orders])
@ -515,11 +589,10 @@ def overview(range: str = Query(_DEFAULT_RANGE, description="finished-figures wi
reason_texts = _fetch_reason_texts()
temper_sent = _fetch_temper_sent(plant)
active_carts = _fetch_active_carts(plant)
finished_carts = _fetch_finished_carts(plant, window_min)
return _build_payload(
plant=plant,
range_code=code,
range_code=range_label,
presses=presses,
active_orders=active_orders,
rejects=rejects,

1
dist/assets/index-Chqsh0Wh.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-YD0yVzL0.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-Chqsh0Wh.css">
</head>
<body>
<div id="app"></div>

View File

@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import OrderLine from '@/components/OrderLine.vue'
import PressTag from '@/components/PressTag.vue'
import { orderColor } from '@/data/orderColors'
import type { FinishedSummary, FinishedCart } from '@/types/overview'
@ -42,6 +43,7 @@ const maxByOrder = computed(() => Math.max(1, ...props.finished.byOrder.map((o)
<div class="byorder">
<div v-for="o in finished.byOrder" :key="o.orderNo" class="row">
<span class="nm">{{ o.orderNo }}</span>
<PressTag v-if="o.press" :press="o.press" />
<span class="track"><i :style="{ width: (o.parts / maxByOrder) * 100 + '%', background: orderColor(o.colorIndex) }"></i></span>
<span class="val">{{ de(o.parts) }}</span>
</div>
@ -53,6 +55,7 @@ const maxByOrder = computed(() => Math.max(1, ...props.finished.byOrder.map((o)
<div v-for="c in list" :key="c.cartId" class="fcart">
<div class="fhd">
<span class="cid">{{ c.cartId }}</span>
<PressTag :press="c.press" />
<span class="ago">{{ c.ago }}</span>
</div>
<div class="ols">
@ -183,7 +186,7 @@ const maxByOrder = computed(() => Math.max(1, ...props.finished.byOrder.map((o)
scrollbar-width: thin;
}
.fcart {
flex: 0 0 148px;
flex: 0 0 168px;
border: 1px solid var(--line);
border-top: 2px solid var(--done);
border-radius: var(--r);
@ -192,7 +195,7 @@ const maxByOrder = computed(() => Math.max(1, ...props.finished.byOrder.map((o)
.fhd {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
margin-bottom: 4px;
}
.cid {

View File

@ -2,11 +2,15 @@
import { useI18n } from 'vue-i18n'
import { ORDER_COLORS } from '@/data/orderColors'
// `flat` drops the panel chrome and adds a top divider so this legend can be fused
// as the second row of a shared panel below the header bar.
defineProps<{ flat?: boolean }>()
const { t } = useI18n()
</script>
<template>
<div class="flow">
<div class="flow" :class="{ flat }">
<div class="node">
<span class="sw" :style="{ background: 'var(--press)' }"></span>
<span><span class="nm">{{ t('flow.presses') }}</span> <span class="sub">{{ t('flow.pressesSub') }}</span></span>
@ -35,6 +39,14 @@ const { t } = useI18n()
background: var(--panel);
border-radius: var(--r);
}
.flow.flat {
margin: 0;
border: 0;
border-top: 1px solid var(--line);
background: none;
border-radius: 0;
padding: 9px 16px;
}
.node {
display: flex;
align-items: center;

View File

@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import OrderLine from '@/components/OrderLine.vue'
import PressTag from '@/components/PressTag.vue'
import type { OvenBatch } from '@/types/overview'
const props = defineProps<{ batches: OvenBatch[]; tempC?: number | null; avgMin: number }>()
@ -50,6 +51,7 @@ function trayStyle(b: OvenBatch) {
<div v-for="b in sorted" :key="b.cartId" class="tray" :class="{ hot: b.remainingMin <= 15 }" :style="trayStyle(b)">
<div class="thd">
<span class="cid">{{ b.cartId }}</span>
<PressTag :press="b.press" />
<span v-if="b.cartId === nextOutId" class="nextout">{{ t('oven.nextOut') }}</span>
<span class="rem" :style="{ color: b.remainingMin <= 15 ? 'var(--hot)' : 'var(--oven)' }">
{{ b.remainingMin }}<span class="remu"> {{ t('oven.unit') }}</span>

View File

@ -3,7 +3,9 @@ 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 }>()
// `flat` drops the panel chrome (border/background/radius) so this bar can be fused
// as the top row of a shared panel with the flow legend below it.
defineProps<{ plant: string; range: RangeCode; sample: boolean; disconnected?: boolean; embedded?: boolean; flat?: boolean }>()
const emit = defineEmits<{ (e: 'update:range', code: RangeCode): void }>()
const { t } = useI18n()
@ -24,15 +26,17 @@ onUnmounted(() => {
</script>
<template>
<div class="bar">
<div class="bar" :class="{ flat }">
<span class="mark">{{ t('app.brand') }}</span>
<span class="title">{{ plant }} · <b>{{ t('app.line') }}</b> · {{ t('app.subtitle') }}</span>
<span class="title">{{ plant }} · <b>{{ t('app.line') }}</b></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="clock num">{{ clock }}</span>
<span class="rangep" role="group" :aria-label="t('range.label')">
<!-- In a Grafana embed the range is driven by Grafana's own time picker, so the
native one is hidden to avoid two competing time controls. -->
<span v-if="!embedded" class="rangep" role="group" :aria-label="t('range.label')">
<span class="rl">{{ t('range.label') }}</span>
<button
v-for="code in RANGES"
@ -58,6 +62,12 @@ onUnmounted(() => {
border-radius: var(--r);
flex-wrap: wrap;
}
.bar.flat {
border: 0;
background: none;
border-radius: 0;
padding: 10px 16px;
}
.mark {
font-family: var(--mono);
font-weight: 700;

View File

@ -2,6 +2,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import OrderLine from '@/components/OrderLine.vue'
import PressTag from '@/components/PressTag.vue'
import type { CartOnWay } from '@/types/overview'
const props = defineProps<{ carts: CartOnWay[] }>()
@ -21,6 +22,7 @@ const partsOf = (c: CartOnWay) => c.orders.reduce((s, o) => s + o.qty, 0)
<div v-for="cart in carts" :key="cart.cartId" class="cart">
<div class="chd">
<span class="cid">{{ cart.cartId }}</span>
<PressTag :press="cart.press" />
<span class="cp">{{ t('lane.parts', { n: partsOf(cart) }) }}</span>
</div>
<div class="cbody">

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
// A small badge that stamps a cart's origin press (machine) number. Shown on every
// cart across the line on-the-way, in the oven, finished plus the finished
// per-order breakdown, so an operator can always trace parts back to their press.
// Purple box (press accent --press), white/ink text to match the surrounding labels.
defineProps<{ press: string }>()
</script>
<template>
<span class="ptag" :title="press">{{ press }}</span>
</template>
<style scoped>
.ptag {
display: inline-flex;
align-items: center;
flex: none;
padding: 1px 5px;
border: 1px solid rgba(167, 139, 250, 0.5);
border-radius: var(--r);
background: rgba(167, 139, 250, 0.18);
color: var(--ink);
font-family: var(--mono);
font-size: 0.62rem;
font-weight: 800;
line-height: 1.35;
letter-spacing: 0.02em;
}
</style>

View File

@ -31,6 +31,7 @@ function chips(...keys: string[]): OrderChip[] {
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_PRESS: Record<string, string> = { o1: 'P1', o2: 'P6', o3: 'P5', o4: 'P8' }
/** Build the sample payload for a range (finished figures scale; live stats don't).
*
@ -55,29 +56,29 @@ export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
]
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 }],
P3: [{ cartId: 'C-014a', press: 'P3', orders: portions(['o1', 54], ['o2', 42]), etaMin: 3 }],
P5: [{ cartId: 'C-014b', press: 'P5', 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 },
{ cartId: 'C-015', press: 'P8', orders: portions(['o1', 30], ['o4', 30], ['o2', 30], ['o3', 30]), etaMin: 9 },
{ cartId: 'C-019', press: 'P8', orders: portions(['o4', 36]), etaMin: 14 },
{ cartId: 'C-020', press: 'P8', 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 },
{ cartId: 'C-011', press: 'P1', orders: portions(['o1', 88]), totalMin: 120, remainingMin: 42 },
{ cartId: 'C-012', press: 'P6', orders: portions(['o2', 60], ['o4', 44]), totalMin: 120, remainingMin: 71 },
{ cartId: 'C-013', press: 'P5', orders: portions(['o3', 72]), totalMin: 120, remainingMin: 12 },
{ cartId: 'C-016', press: 'P1', orders: portions(['o1', 40], ['o2', 36]), totalMin: 120, remainingMin: 98 },
{ cartId: 'C-017', press: 'P8', 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' },
{ cartId: 'C-010', press: 'P6', orders: portions(['o2', 50], ['o3', 40]), ago: 'vor 8 min' },
{ cartId: 'C-009', press: 'P9', orders: portions(['o1', 64]), ago: 'vor 21 min' },
{ cartId: 'C-008', press: 'P8', orders: portions(['o4', 58]), ago: 'vor 35 min' },
{ cartId: 'C-007', press: 'P1', orders: portions(['o2', 44], ['o1', 20]), ago: 'vor 52 min' },
{ cartId: 'C-006', press: 'P5', orders: portions(['o3', 72]), ago: 'vor 1 h 6 min' },
]
return {
@ -99,6 +100,7 @@ export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
orderNo: ORDERS[k][0],
colorIndex: ORDERS[k][1],
parts: Math.round(v * f),
press: FINISHED_BY_ORDER_PRESS[k],
})),
},
finishedList: FINISHED_LIST,

37
src/embed.ts Normal file
View File

@ -0,0 +1,37 @@
// Grafana-embed detection + time range, read from the iframe URL.
//
// When this board is embedded in a Grafana text-panel iframe, the dashboard is
// configured to pass `embed=grafana` plus Grafana's own time range as `from`/`to`
// (epoch ms, via Grafana's ${__from}/${__to}). In that mode the board hides its own
// range picker and queries Grafana's EXACT [from,to] window instead of a preset.
//
// A Grafana time-range change reloads the iframe with new from/to in the URL, so
// reading the URL once per load is sufficient — no message channel needed.
function urlParams(): URLSearchParams {
// The app uses hash-based routing, so params usually sit after the '#'
// (`…/#/?embed=grafana&from=…`). Tolerate them before it too and merge both.
const merged = new URLSearchParams()
const add = (qs: string) => {
for (const [k, v] of new URLSearchParams(qs)) merged.set(k, v)
}
add(window.location.search.replace(/^\?/, ''))
const hash = window.location.hash
const q = hash.indexOf('?')
if (q >= 0) add(hash.slice(q + 1))
return merged
}
/** True when hosted in Grafana (`?embed=grafana`) — hide the native range picker. */
export function isGrafanaEmbed(): boolean {
return urlParams().get('embed') === 'grafana'
}
/** Grafana's absolute window as epoch-ms `{ from, to }`, or null if absent/invalid. */
export function grafanaWindow(): { from: number; to: number } | null {
const p = urlParams()
const from = Number(p.get('from'))
const to = Number(p.get('to'))
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) return null
return { from, to }
}

View File

@ -1,8 +1,8 @@
export default {
app: {
brand: 'PressV',
brand: 'Temperprozess',
plant: 'Werk',
line: 'Temperprozess',
line: 'Live-Übersicht',
subtitle: 'Live-Übersicht',
live: 'Live',
demo: 'Demo-Daten',

View File

@ -1,9 +1,9 @@
// English mirrors de.ts exactly (same key shape) — de is the canonical schema.
export default {
app: {
brand: 'PressV',
brand: 'Temperprozess',
plant: 'Plant',
line: 'Temperprozess',
line: 'Live Overview',
subtitle: 'Live Overview',
live: 'Live',
demo: 'Sample data',

View File

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
import { sampleOverview } from '@/data/sample'
import { isGrafanaEmbed, grafanaWindow } from '@/embed'
import type { OverviewPayload, RangeCode } from '@/types/overview'
// Mirrors PressV's productionOverview store: sync loadCache() → hydrate() cold
@ -19,6 +20,12 @@ const CACHE_KEY = 'temper_overview_payload'
export const useTemperOverviewStore = defineStore('temperOverview', () => {
const payload = ref<OverviewPayload | null>(null)
const range = ref<RangeCode>('1d')
// Grafana embed: when hosted in a Grafana iframe (?embed=grafana), the native range
// picker is hidden and the finished-figures window is driven by Grafana's own time
// range (from/to epoch ms) instead of the preset `range`. Read once from the URL —
// a Grafana time change reloads the iframe, which re-runs this store setup.
const embedded = ref(isGrafanaEmbed())
const grafanaWin = embedded.value ? grafanaWindow() : null
const loading = ref(false)
const error = ref<string | null>(null)
const usingSample = ref(false)
@ -61,7 +68,9 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
async function refresh() {
error.value = null
try {
const res = await api.get<OverviewPayload>('/overview', { params: { range: range.value } })
// Grafana embed → exact from→to window; standalone → the preset range.
const params = grafanaWin ? { from: grafanaWin.from, to: grafanaWin.to } : { range: range.value }
const res = await api.get<OverviewPayload>('/overview', { params })
// 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
@ -126,6 +135,7 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
return {
payload,
range,
embedded,
loading,
error,
usingSample,

View File

@ -34,6 +34,8 @@ export interface PressInfo {
export interface CartOnWay {
cartId: string
/** origin press(es) that loaded the cart (e.g. "P3"; "P3+P5" if mixed) */
press: string
orders: OrderPortion[]
/** minutes until it enters the oven */
etaMin: number
@ -41,6 +43,8 @@ export interface CartOnWay {
export interface OvenBatch {
cartId: string
/** origin press(es) that loaded the cart (e.g. "P3"; "P3+P5" if mixed) */
press: string
orders: OrderPortion[]
totalMin: number
remainingMin: number
@ -50,6 +54,8 @@ export interface FinishedByOrder {
orderNo: string
colorIndex: number
parts: number
/** origin press(es) that ran the order in this window (e.g. "P3"; "P3+P5" if mixed) */
press: string
}
export interface FinishedSummary {
@ -62,6 +68,8 @@ export interface FinishedSummary {
export interface FinishedCart {
cartId: string
/** origin press(es) that loaded the cart (e.g. "P3"; "P3+P5" if mixed) */
press: string
orders: OrderPortion[]
ago: string
}

View File

@ -22,7 +22,7 @@ import { useTemperOverviewStore } from '@/stores/temperOverview'
const { t } = useI18n()
const store = useTemperOverviewStore()
const { payload, range, hasData, usingSample, disconnected } = storeToRefs(store)
const { payload, range, embedded, hasData, usingSample, disconnected } = storeToRefs(store)
onMounted(async () => {
await store.hydrate()
@ -34,14 +34,19 @@ onUnmounted(() => store.stopPolling())
<template>
<div class="wrap">
<template v-if="hasData && payload">
<OverviewHeader
:plant="payload.plant"
:range="range"
:sample="usingSample"
:disconnected="disconnected"
@update:range="store.setRange"
/>
<FlowLegend />
<!-- Header bar + flow legend fused into one denser panel (two rows). -->
<div class="toppanel">
<OverviewHeader
flat
:plant="payload.plant"
:range="range"
:sample="usingSample"
:disconnected="disconnected"
:embedded="embedded"
@update:range="store.setRange"
/>
<FlowLegend flat />
</div>
<KpiStrip :payload="payload" />
<ProductionLine :payload="payload" />
</template>
@ -60,6 +65,13 @@ onUnmounted(() => store.stopPolling())
margin: 0 auto;
padding: 20px 22px 80px;
}
.toppanel {
border: 1px solid var(--line);
background: var(--panel);
border-radius: var(--r);
margin-bottom: 14px;
overflow: hidden;
}
.state {
display: flex;
flex-direction: column;