Compare commits
4 Commits
173fdaaf63
...
28b648d335
| Author | SHA1 | Date | |
|---|---|---|---|
| 28b648d335 | |||
| adecca9e9a | |||
| 11fc16e730 | |||
| 9be247c32c |
@ -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" }]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
1
dist/assets/index-Chqsh0Wh.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-CmR_19mg.css
vendored
1
dist/assets/index-CmR_19mg.css
vendored
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-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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
29
src/components/PressTag.vue
Normal file
29
src/components/PressTag.vue
Normal 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>
|
||||
@ -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
37
src/embed.ts
Normal 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 }
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<!-- 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 />
|
||||
<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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user