Compare commits

..

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

20 changed files with 63 additions and 271 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","press":"P3","orders":[{"orderNo":"O-4471","qty":54,"colorIndex":0}],"etaMin":3 }] },
"oven": [{ "cartId":"C-011","press":"P1","orders":[],"totalMin":120,"remainingMin":42 }],
"onWay": { "P3":[{ "cartId":"C-014a","orders":[{"orderNo":"O-4471","qty":54,"colorIndex":0}],"etaMin":3 }] },
"oven": [{ "cartId":"C-011","orders":[],"totalMin":120,"remainingMin":42 }],
"finished": { "carts":47,"parts":5120,"scrap":63,"avgOvenMin":118,
"byOrder":[{"orderNo":"O-4471","colorIndex":0,"parts":1680,"press":"P1"}] },
"finishedList": [{ "cartId":"C-010","press":"P6","orders":[],"ago":"vor 8 min" }]
"byOrder":[{"orderNo":"O-4471","colorIndex":0,"parts":1680}] },
"finishedList": [{ "cartId":"C-010","orders":[],"ago":"vor 8 min" }]
}
```

View File

@ -31,14 +31,6 @@ 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,16 +82,6 @@ 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
@ -134,18 +124,6 @@ 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) ─────
@ -330,33 +308,9 @@ def _fetch_active_carts(plant: str) -> 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]]:
def _fetch_finished_carts(plant: str, window_min: int) -> list[dict[str, Any]]:
"""Carts out of the oven within the selected window — one row per (cart,order),
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}
newest first. Excludes reprints."""
try:
rows = run_select_query(
"SELECT ttCartId AS cartId, ttId, ttPress AS press, ttOrdernumber AS orderno, "
@ -365,10 +319,10 @@ def _fetch_finished_carts(
"TIMESTAMPDIFF(MINUTE, ttOutOfOvenAt, NOW()) AS agoMin "
"FROM production.temper_tracking "
"WHERE ttPlant = :plant AND ttReprintOf IS NULL "
" AND ttOutOfOvenAt IS NOT NULL "
+ bound_sql
+ "ORDER BY ttOutOfOvenAt DESC, ttId DESC",
binds,
" 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},
)
except Exception as e: # noqa: BLE001
logger.warning("temper_overview: finished carts unavailable: %s", e)
@ -473,7 +427,6 @@ 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"]),
}
@ -490,7 +443,6 @@ 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),
@ -505,15 +457,10 @@ 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:
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_parts[c["orderno"]] = by_order_parts.get(c["orderno"], 0) + c["qty"]
by_order = [
{"orderNo": o, "colorIndex": colors.get(o, 0), "parts": p, "press": "+".join(by_order_press.get(o, []))}
{"orderNo": o, "colorIndex": colors.get(o, 0), "parts": p}
for o, p in sorted(by_order_parts.items(), key=lambda kv: kv[1], reverse=True)
]
@ -528,7 +475,6 @@ 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)),
}
@ -556,30 +502,10 @@ 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"),
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:
def overview(range: str = Query(_DEFAULT_RANGE, description="finished-figures window: 12h|1d|7d|14d|30d")) -> dict[str, Any]:
code = _range_code(range)
finished_carts = _fetch_finished_carts(plant, window_min=_RANGE_MINUTES[code])
range_label = code
window_min = _RANGE_MINUTES[code]
plant = PLANT_NAME
presses = _fetch_presses(plant)
active_orders = _fetch_active_orders(plant)
@ -589,10 +515,11 @@ def overview(
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=range_label,
range_code=code,
presses=presses,
active_orders=active_orders,
rejects=rejects,

File diff suppressed because one or more lines are too long

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
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-YD0yVzL0.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_overview/assets/index-Chqsh0Wh.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>

View File

@ -2,7 +2,6 @@
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'
@ -43,7 +42,6 @@ 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>
@ -55,7 +53,6 @@ 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">
@ -186,7 +183,7 @@ const maxByOrder = computed(() => Math.max(1, ...props.finished.byOrder.map((o)
scrollbar-width: thin;
}
.fcart {
flex: 0 0 168px;
flex: 0 0 148px;
border: 1px solid var(--line);
border-top: 2px solid var(--done);
border-radius: var(--r);
@ -195,7 +192,7 @@ const maxByOrder = computed(() => Math.max(1, ...props.finished.byOrder.map((o)
.fhd {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
margin-bottom: 4px;
}
.cid {

View File

@ -2,15 +2,11 @@
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" :class="{ flat }">
<div class="flow">
<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>
@ -39,14 +35,6 @@ 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,7 +2,6 @@
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 }>()
@ -51,7 +50,6 @@ 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,9 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RangeCode } from '@/types/overview'
// `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 }>()
defineProps<{ plant: string; range: RangeCode; sample: boolean; disconnected?: boolean }>()
const emit = defineEmits<{ (e: 'update:range', code: RangeCode): void }>()
const { t } = useI18n()
@ -26,17 +24,15 @@ onUnmounted(() => {
</script>
<template>
<div class="bar" :class="{ flat }">
<div class="bar">
<span class="mark">{{ t('app.brand') }}</span>
<span class="title">{{ plant }} · <b>{{ t('app.line') }}</b></span>
<span class="title">{{ plant }} · <b>{{ t('app.line') }}</b> · {{ t('app.subtitle') }}</span>
<span v-if="sample" class="demo">{{ t('app.demo') }}</span>
<span 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>
<!-- 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="rangep" role="group" :aria-label="t('range.label')">
<span class="rl">{{ t('range.label') }}</span>
<button
v-for="code in RANGES"
@ -62,12 +58,6 @@ 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,7 +2,6 @@
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[] }>()
@ -22,7 +21,6 @@ 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

@ -1,29 +0,0 @@
<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,7 +31,6 @@ 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).
*
@ -56,29 +55,29 @@ export function sampleOverview(range: RangeCode = '1d'): OverviewPayload {
]
const ON_WAY: Record<string, CartOnWay[]> = {
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 }],
P3: [{ cartId: 'C-014a', orders: portions(['o1', 54], ['o2', 42]), etaMin: 3 }],
P5: [{ cartId: 'C-014b', orders: portions(['o3', 60]), etaMin: 7 }],
P8: [
{ cartId: 'C-015', 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 },
{ cartId: 'C-015', orders: portions(['o1', 30], ['o4', 30], ['o2', 30], ['o3', 30]), etaMin: 9 },
{ cartId: 'C-019', orders: portions(['o4', 36]), etaMin: 14 },
{ cartId: 'C-020', orders: portions(['o1', 28]), etaMin: 19 },
],
}
const OVEN: OvenBatch[] = [
{ cartId: 'C-011', 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 },
{ cartId: 'C-011', orders: portions(['o1', 88]), totalMin: 120, remainingMin: 42 },
{ cartId: 'C-012', orders: portions(['o2', 60], ['o4', 44]), totalMin: 120, remainingMin: 71 },
{ cartId: 'C-013', orders: portions(['o3', 72]), totalMin: 120, remainingMin: 12 },
{ cartId: 'C-016', orders: portions(['o1', 40], ['o2', 36]), totalMin: 120, remainingMin: 98 },
{ cartId: 'C-017', orders: portions(['o4', 80]), totalMin: 120, remainingMin: 104 },
]
const FINISHED_LIST = [
{ cartId: 'C-010', 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' },
{ cartId: 'C-010', orders: portions(['o2', 50], ['o3', 40]), ago: 'vor 8 min' },
{ cartId: 'C-009', orders: portions(['o1', 64]), ago: 'vor 21 min' },
{ cartId: 'C-008', orders: portions(['o4', 58]), ago: 'vor 35 min' },
{ cartId: 'C-007', orders: portions(['o2', 44], ['o1', 20]), ago: 'vor 52 min' },
{ cartId: 'C-006', orders: portions(['o3', 72]), ago: 'vor 1 h 6 min' },
]
return {
@ -100,7 +99,6 @@ 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,

View File

@ -1,37 +0,0 @@
// 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: 'Temperprozess',
brand: 'PressV',
plant: 'Werk',
line: 'Live-Übersicht',
line: 'Temperprozess',
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: 'Temperprozess',
brand: 'PressV',
plant: 'Plant',
line: 'Live Overview',
line: 'Temperprozess',
subtitle: 'Live Overview',
live: 'Live',
demo: 'Sample data',

View File

@ -2,7 +2,6 @@ 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
@ -20,12 +19,6 @@ 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)
@ -68,9 +61,7 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
async function refresh() {
error.value = null
try {
// 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 })
const res = await api.get<OverviewPayload>('/overview', { params: { range: range.value } })
// 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
@ -135,7 +126,6 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
return {
payload,
range,
embedded,
loading,
error,
usingSample,

View File

@ -34,8 +34,6 @@ 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
@ -43,8 +41,6 @@ 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
@ -54,8 +50,6 @@ 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 {
@ -68,8 +62,6 @@ 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, embedded, hasData, usingSample, disconnected } = storeToRefs(store)
const { payload, range, hasData, usingSample, disconnected } = storeToRefs(store)
onMounted(async () => {
await store.hydrate()
@ -34,19 +34,14 @@ onUnmounted(() => store.stopPolling())
<template>
<div class="wrap">
<template v-if="hasData && payload">
<!-- 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>
<FlowLegend />
<KpiStrip :payload="payload" />
<ProductionLine :payload="payload" />
</template>
@ -65,13 +60,6 @@ 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;