Merge branch 'fix/spa-reconnect-no-demo-data' into main
Brings press-number badges, the header rework (Temperprozess pill, Live-Uebersicht in blue), and the fused header+legend top panel onto main.
This commit is contained in:
commit
28b648d335
@ -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" }]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
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 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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
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_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
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 {
|
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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
<OverviewHeader
|
<!-- Header bar + flow legend fused into one denser panel (two rows). -->
|
||||||
:plant="payload.plant"
|
<div class="toppanel">
|
||||||
:range="range"
|
<OverviewHeader
|
||||||
:sample="usingSample"
|
flat
|
||||||
:disconnected="disconnected"
|
:plant="payload.plant"
|
||||||
@update:range="store.setRange"
|
:range="range"
|
||||||
/>
|
:sample="usingSample"
|
||||||
<FlowLegend />
|
:disconnected="disconnected"
|
||||||
|
:embedded="embedded"
|
||||||
|
@update:range="store.setRange"
|
||||||
|
/>
|
||||||
|
<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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user