Compare commits

..

4 Commits

Author SHA1 Message Date
28b648d335 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.
2026-07-10 16:32:52 +02:00
adecca9e9a feat(overview): press-number badges, header rework, fused top panel
- Stamp the origin press (machine) number on every cart across the line:
  on-the-way, in-oven, finished tiles, and the finished by-order list.
  New shared PressTag.vue badge (icon-free, ink/white text on a violet box).
- Thread `press` through the payload (CartOnWay/OvenBatch/FinishedCart/
  FinishedByOrder) — backend derives distinct press(es) per cart/order.
- Header: pill now "Temperprozess" (was PressV); "Live-Uebersicht" moved into
  the blue accent slot; dropped the duplicate subtitle.
- Fuse the header bar and flow legend into one denser two-row panel (flat
  prop on both + a shared .toppanel wrapper).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:25:53 +02:00
11fc16e730 feat: Grafana-embed mode — hide native picker, follow Grafana time range
With ?embed=grafana on the URL the board hides its in-page range picker and drives the finished-figures window from Grafana's exact from/to (epoch ms) instead of a preset. Backend /overview now accepts from/to (aliased) via FROM_UNIXTIME, which resolves in the same DB session clock as NOW() so timezone handling is unchanged. Standalone behaviour (own picker + preset range) is untouched. Includes rebuilt dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:13:47 +02:00
9be247c32c feat: opt into gateway CORS for cross-origin Grafana iframe embed
Declare CORS_ALLOW_ORIGINS=["*"]. The kernel loader reads this opt-in and emits Access-Control-Allow-Origin for this module's prefix only, so the SPA's Vite `crossorigin` assets load inside a sandboxed Grafana iframe (opaque origin) instead of being CORS-blocked (blank page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:56:33 +02:00
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, "ovenTempC": 185, "ovenAvgMin": 120, "sample": true,
"presses": [{ "name":"P1","status":"run","orders":[{"orderNo":"O-4471","colorIndex":0}], "presses": [{ "name":"P1","status":"run","orders":[{"orderNo":"O-4471","colorIndex":0}],
"good":1240,"cycleTime":"4.2","notVerladen":34,"note":null,"placeholder":false }], "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 }] }, "onWay": { "P3":[{ "cartId":"C-014a","press":"P3","orders":[{"orderNo":"O-4471","qty":54,"colorIndex":0}],"etaMin":3 }] },
"oven": [{ "cartId":"C-011","orders":[],"totalMin":120,"remainingMin":42 }], "oven": [{ "cartId":"C-011","press":"P1","orders":[],"totalMin":120,"remainingMin":42 }],
"finished": { "carts":47,"parts":5120,"scrap":63,"avgOvenMin":118, "finished": { "carts":47,"parts":5120,"scrap":63,"avgOvenMin":118,
"byOrder":[{"orderNo":"O-4471","colorIndex":0,"parts":1680}] }, "byOrder":[{"orderNo":"O-4471","colorIndex":0,"parts":1680,"press":"P1"}] },
"finishedList": [{ "cartId":"C-010","orders":[],"ago":"vor 8 min" }] "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). # MUST match vite.config.ts `base` (with a trailing slash on the Vite side).
MODULE_PREFIX = "/module/temper_overview" 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: def register(app: FastAPI) -> None:
"""Mount the data API and the SPA onto the gateway app. """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 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]: def _color_map(*ordernumbers: str) -> dict[str, int]:
"""Assign each distinct order number a colour INDEX 0..3 (the frontend maps the """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 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 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) ───── # ── 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), """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: try:
rows = run_select_query( rows = run_select_query(
"SELECT ttCartId AS cartId, ttId, ttPress AS press, ttOrdernumber AS orderno, " "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 " "TIMESTAMPDIFF(MINUTE, ttOutOfOvenAt, NOW()) AS agoMin "
"FROM production.temper_tracking " "FROM production.temper_tracking "
"WHERE ttPlant = :plant AND ttReprintOf IS NULL " "WHERE ttPlant = :plant AND ttReprintOf IS NULL "
" AND ttOutOfOvenAt IS NOT NULL AND NOW() >= ttOutOfOvenAt " " AND ttOutOfOvenAt IS NOT NULL "
" AND ttOutOfOvenAt >= NOW() - INTERVAL :window_min MINUTE " + bound_sql
"ORDER BY ttOutOfOvenAt DESC, ttId DESC", + "ORDER BY ttOutOfOvenAt DESC, ttId DESC",
{"plant": plant, "window_min": window_min}, binds,
) )
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("temper_overview: finished carts unavailable: %s", e) logger.warning("temper_overview: finished carts unavailable: %s", e)
@ -427,6 +473,7 @@ def _build_payload(
on_way[press] = [ on_way[press] = [
{ {
"cartId": cart["cartId"], "cartId": cart["cartId"],
"press": _cart_press(cart["_rows"]),
"orders": cart["orders"], "orders": cart["orders"],
"etaMin": min(r["etaMin"] for r in cart["_rows"]), "etaMin": min(r["etaMin"] for r in cart["_rows"]),
} }
@ -443,6 +490,7 @@ def _build_payload(
oven.append( oven.append(
{ {
"cartId": cart["cartId"], "cartId": cart["cartId"],
"press": _cart_press(rows),
"orders": cart["orders"], "orders": cart["orders"],
"totalMin": total_min, "totalMin": total_min,
"remainingMin": min(r["remainingMin"] for r in rows), "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 finished_avg = round(sum(temper_mins) / len(temper_mins)) if temper_mins else DEFAULT_OVEN_MINUTES
by_order_parts: dict[str, int] = {} by_order_parts: dict[str, int] = {}
by_order_press: dict[str, list[str]] = {}
for c in finished_carts: 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 = [ 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) 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 = [ finished_list = [
{ {
"cartId": cart["cartId"], "cartId": cart["cartId"],
"press": _cart_press(cart["_rows"]),
"orders": cart["orders"], "orders": cart["orders"],
"ago": _fmt_ago(min((r["agoMin"] for r in cart["_rows"] if r["agoMin"] is not None), default=None)), "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)") @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]: def overview(
code = _range_code(range) range: str = Query(_DEFAULT_RANGE, description="finished-figures window: 12h|1d|7d|14d|30d"),
window_min = _RANGE_MINUTES[code] 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 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) presses = _fetch_presses(plant)
active_orders = _fetch_active_orders(plant) active_orders = _fetch_active_orders(plant)
rejects = _fetch_rejects([o["orderno"] for o in active_orders]) 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() reason_texts = _fetch_reason_texts()
temper_sent = _fetch_temper_sent(plant) temper_sent = _fetch_temper_sent(plant)
active_carts = _fetch_active_carts(plant) active_carts = _fetch_active_carts(plant)
finished_carts = _fetch_finished_carts(plant, window_min)
return _build_payload( return _build_payload(
plant=plant, plant=plant,
range_code=code, range_code=range_label,
presses=presses, presses=presses,
active_orders=active_orders, active_orders=active_orders,
rejects=rejects, 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 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-YD0yVzL0.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-Chqsh0Wh.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

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

View File

@ -2,11 +2,15 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ORDER_COLORS } from '@/data/orderColors' 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() const { t } = useI18n()
</script> </script>
<template> <template>
<div class="flow"> <div class="flow" :class="{ flat }">
<div class="node"> <div class="node">
<span class="sw" :style="{ background: 'var(--press)' }"></span> <span class="sw" :style="{ background: 'var(--press)' }"></span>
<span><span class="nm">{{ t('flow.presses') }}</span> <span class="sub">{{ t('flow.pressesSub') }}</span></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); background: var(--panel);
border-radius: var(--r); 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 { .node {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import OrderLine from '@/components/OrderLine.vue' import OrderLine from '@/components/OrderLine.vue'
import PressTag from '@/components/PressTag.vue'
import type { OvenBatch } from '@/types/overview' import type { OvenBatch } from '@/types/overview'
const props = defineProps<{ batches: OvenBatch[]; tempC?: number | null; avgMin: number }>() 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 v-for="b in sorted" :key="b.cartId" class="tray" :class="{ hot: b.remainingMin <= 15 }" :style="trayStyle(b)">
<div class="thd"> <div class="thd">
<span class="cid">{{ b.cartId }}</span> <span class="cid">{{ b.cartId }}</span>
<PressTag :press="b.press" />
<span v-if="b.cartId === nextOutId" class="nextout">{{ t('oven.nextOut') }}</span> <span v-if="b.cartId === nextOutId" class="nextout">{{ t('oven.nextOut') }}</span>
<span class="rem" :style="{ color: b.remainingMin <= 15 ? 'var(--hot)' : 'var(--oven)' }"> <span class="rem" :style="{ color: b.remainingMin <= 15 ? 'var(--hot)' : 'var(--oven)' }">
{{ b.remainingMin }}<span class="remu"> {{ t('oven.unit') }}</span> {{ 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 { 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 }>() // `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 emit = defineEmits<{ (e: 'update:range', code: RangeCode): void }>()
const { t } = useI18n() const { t } = useI18n()
@ -24,15 +26,17 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="bar"> <div class="bar" :class="{ flat }">
<span class="mark">{{ t('app.brand') }}</span> <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 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 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 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')"> <!-- 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> <span class="rl">{{ t('range.label') }}</span>
<button <button
v-for="code in RANGES" v-for="code in RANGES"
@ -58,6 +62,12 @@ onUnmounted(() => {
border-radius: var(--r); border-radius: var(--r);
flex-wrap: wrap; flex-wrap: wrap;
} }
.bar.flat {
border: 0;
background: none;
border-radius: 0;
padding: 10px 16px;
}
.mark { .mark {
font-family: var(--mono); font-family: var(--mono);
font-weight: 700; font-weight: 700;

View File

@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import OrderLine from '@/components/OrderLine.vue' import OrderLine from '@/components/OrderLine.vue'
import PressTag from '@/components/PressTag.vue'
import type { CartOnWay } from '@/types/overview' import type { CartOnWay } from '@/types/overview'
const props = defineProps<{ carts: CartOnWay[] }>() 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 v-for="cart in carts" :key="cart.cartId" class="cart">
<div class="chd"> <div class="chd">
<span class="cid">{{ cart.cartId }}</span> <span class="cid">{{ cart.cartId }}</span>
<PressTag :press="cart.press" />
<span class="cp">{{ t('lane.parts', { n: partsOf(cart) }) }}</span> <span class="cp">{{ t('lane.parts', { n: partsOf(cart) }) }}</span>
</div> </div>
<div class="cbody"> <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_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 }
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). /** 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[]> = { const ON_WAY: Record<string, CartOnWay[]> = {
P3: [{ cartId: 'C-014a', orders: portions(['o1', 54], ['o2', 42]), etaMin: 3 }], P3: [{ cartId: 'C-014a', press: 'P3', orders: portions(['o1', 54], ['o2', 42]), etaMin: 3 }],
P5: [{ cartId: 'C-014b', orders: portions(['o3', 60]), etaMin: 7 }], P5: [{ cartId: 'C-014b', press: 'P5', orders: portions(['o3', 60]), etaMin: 7 }],
P8: [ P8: [
{ cartId: 'C-015', orders: portions(['o1', 30], ['o4', 30], ['o2', 30], ['o3', 30]), etaMin: 9 }, { cartId: 'C-015', press: 'P8', orders: portions(['o1', 30], ['o4', 30], ['o2', 30], ['o3', 30]), etaMin: 9 },
{ cartId: 'C-019', orders: portions(['o4', 36]), etaMin: 14 }, { cartId: 'C-019', press: 'P8', orders: portions(['o4', 36]), etaMin: 14 },
{ cartId: 'C-020', orders: portions(['o1', 28]), etaMin: 19 }, { cartId: 'C-020', press: 'P8', 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-011', press: 'P1', orders: portions(['o1', 88]), totalMin: 120, remainingMin: 42 },
{ cartId: 'C-012', orders: portions(['o2', 60], ['o4', 44]), totalMin: 120, remainingMin: 71 }, { cartId: 'C-012', press: 'P6', orders: portions(['o2', 60], ['o4', 44]), totalMin: 120, remainingMin: 71 },
{ cartId: 'C-013', orders: portions(['o3', 72]), totalMin: 120, remainingMin: 12 }, { cartId: 'C-013', press: 'P5', orders: portions(['o3', 72]), totalMin: 120, remainingMin: 12 },
{ cartId: 'C-016', orders: portions(['o1', 40], ['o2', 36]), totalMin: 120, remainingMin: 98 }, { cartId: 'C-016', press: 'P1', orders: portions(['o1', 40], ['o2', 36]), totalMin: 120, remainingMin: 98 },
{ cartId: 'C-017', orders: portions(['o4', 80]), totalMin: 120, remainingMin: 104 }, { cartId: 'C-017', press: 'P8', orders: portions(['o4', 80]), totalMin: 120, remainingMin: 104 },
] ]
const FINISHED_LIST = [ const FINISHED_LIST = [
{ cartId: 'C-010', orders: portions(['o2', 50], ['o3', 40]), ago: 'vor 8 min' }, { cartId: 'C-010', press: 'P6', orders: portions(['o2', 50], ['o3', 40]), ago: 'vor 8 min' },
{ cartId: 'C-009', orders: portions(['o1', 64]), ago: 'vor 21 min' }, { cartId: 'C-009', press: 'P9', orders: portions(['o1', 64]), ago: 'vor 21 min' },
{ cartId: 'C-008', orders: portions(['o4', 58]), ago: 'vor 35 min' }, { cartId: 'C-008', press: 'P8', orders: portions(['o4', 58]), ago: 'vor 35 min' },
{ cartId: 'C-007', orders: portions(['o2', 44], ['o1', 20]), ago: 'vor 52 min' }, { cartId: 'C-007', press: 'P1', 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-006', press: 'P5', orders: portions(['o3', 72]), ago: 'vor 1 h 6 min' },
] ]
return { return {
@ -99,6 +100,7 @@ export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
orderNo: ORDERS[k][0], orderNo: ORDERS[k][0],
colorIndex: ORDERS[k][1], colorIndex: ORDERS[k][1],
parts: Math.round(v * f), parts: Math.round(v * f),
press: FINISHED_BY_ORDER_PRESS[k],
})), })),
}, },
finishedList: FINISHED_LIST, 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 { export default {
app: { app: {
brand: 'PressV', brand: 'Temperprozess',
plant: 'Werk', plant: 'Werk',
line: 'Temperprozess', line: 'Live-Übersicht',
subtitle: 'Live-Übersicht', subtitle: 'Live-Übersicht',
live: 'Live', live: 'Live',
demo: 'Demo-Daten', demo: 'Demo-Daten',

View File

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

View File

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import api from '@/api' import api from '@/api'
import { sampleOverview } from '@/data/sample' import { sampleOverview } from '@/data/sample'
import { isGrafanaEmbed, grafanaWindow } from '@/embed'
import type { OverviewPayload, RangeCode } from '@/types/overview' import type { OverviewPayload, RangeCode } from '@/types/overview'
// Mirrors PressV's productionOverview store: sync loadCache() → hydrate() cold // Mirrors PressV's productionOverview store: sync loadCache() → hydrate() cold
@ -19,6 +20,12 @@ const CACHE_KEY = 'temper_overview_payload'
export const useTemperOverviewStore = defineStore('temperOverview', () => { export const useTemperOverviewStore = defineStore('temperOverview', () => {
const payload = ref<OverviewPayload | null>(null) const payload = ref<OverviewPayload | null>(null)
const range = ref<RangeCode>('1d') 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 loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const usingSample = ref(false) const usingSample = ref(false)
@ -61,7 +68,9 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
async function refresh() { async function refresh() {
error.value = null error.value = null
try { 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 // 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
@ -126,6 +135,7 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
return { return {
payload, payload,
range, range,
embedded,
loading, loading,
error, error,
usingSample, usingSample,

View File

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

View File

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