feat: Grafana-embed mode — hide native picker, follow Grafana time range

With ?embed=grafana on the URL the board hides its in-page range picker and drives the finished-figures window from Grafana's exact from/to (epoch ms) instead of a preset. Backend /overview now accepts from/to (aliased) via FROM_UNIXTIME, which resolves in the same DB session clock as NOW() so timezone handling is unchanged. Standalone behaviour (own picker + preset range) is untouched. Includes rebuilt dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-10 15:13:47 +02:00
parent 9be247c32c
commit 11fc16e730
8 changed files with 123 additions and 20 deletions

View File

@ -82,6 +82,16 @@ def _range_code(raw: str) -> str:
return raw if raw in _RANGE_MINUTES else _DEFAULT_RANGE return raw if raw in _RANGE_MINUTES else _DEFAULT_RANGE
def _abs_window_label(from_ms: int, to_ms: int) -> str:
"""Compact human label for an absolute (Grafana) finished-figures window, shown
where the preset code used to be e.g. '26 h', '3 d'. Purely cosmetic."""
minutes = max(0, round((to_ms - from_ms) / 60000))
if minutes < 90:
return f"{minutes} min"
hours = minutes / 60
return f"{round(hours)} h" if hours < 48 else f"{round(hours / 24)} d"
def _color_map(*ordernumbers: str) -> dict[str, int]: def _color_map(*ordernumbers: str) -> dict[str, int]:
"""Assign each distinct order number a colour INDEX 0..3 (the frontend maps the """Assign each distinct order number a colour INDEX 0..3 (the frontend maps the
index hex via ``src/data/orderColors.ts``). Sorted so the mapping is stable for index hex via ``src/data/orderColors.ts``). Sorted so the mapping is stable for
@ -308,9 +318,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 +353,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)
@ -502,11 +536,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 +569,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,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -9,8 +9,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Temperprozess · Live-Übersicht</title> <title>Temperprozess · Live-Übersicht</title>
<script type="module" crossorigin src="/module/temper_overview/assets/index-DUpp_SaJ.js"></script> <script type="module" crossorigin src="/module/temper_overview/assets/index-GswA5CwC.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-CPqj-iy2.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -3,7 +3,7 @@ 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 }>() defineProps<{ plant: string; range: RangeCode; sample: boolean; disconnected?: boolean; embedded?: 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()
@ -32,7 +32,9 @@ onUnmounted(() => {
<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"

37
src/embed.ts Normal file
View File

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

View File

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import api from '@/api' import api from '@/api'
import { sampleOverview } from '@/data/sample' import { sampleOverview } from '@/data/sample'
import { isGrafanaEmbed, grafanaWindow } from '@/embed'
import type { OverviewPayload, RangeCode } from '@/types/overview' import type { OverviewPayload, RangeCode } from '@/types/overview'
// Mirrors PressV's productionOverview store: sync loadCache() → hydrate() cold // Mirrors PressV's productionOverview store: sync loadCache() → hydrate() cold
@ -19,6 +20,12 @@ const CACHE_KEY = 'temper_overview_payload'
export const useTemperOverviewStore = defineStore('temperOverview', () => { export const useTemperOverviewStore = defineStore('temperOverview', () => {
const payload = ref<OverviewPayload | null>(null) const payload = ref<OverviewPayload | null>(null)
const range = ref<RangeCode>('1d') const range = ref<RangeCode>('1d')
// Grafana embed: when hosted in a Grafana iframe (?embed=grafana), the native range
// picker is hidden and the finished-figures window is driven by Grafana's own time
// range (from/to epoch ms) instead of the preset `range`. Read once from the URL —
// a Grafana time change reloads the iframe, which re-runs this store setup.
const embedded = ref(isGrafanaEmbed())
const grafanaWin = embedded.value ? grafanaWindow() : null
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const usingSample = ref(false) const usingSample = ref(false)
@ -61,7 +68,9 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
async function refresh() { async function refresh() {
error.value = null error.value = null
try { try {
const res = await api.get<OverviewPayload>('/overview', { params: { range: range.value } }) // Grafana embed → exact from→to window; standalone → the preset range.
const params = grafanaWin ? { from: grafanaWin.from, to: grafanaWin.to } : { range: range.value }
const res = await api.get<OverviewPayload>('/overview', { params })
// Guard the shape: a dev server (vite dev/preview) SPA-fallbacks unknown // Guard the shape: a dev server (vite dev/preview) SPA-fallbacks unknown
// routes to index.html (HTTP 200 HTML), so a missing backend looks like a // routes to index.html (HTTP 200 HTML), so a missing backend looks like a
// "successful" non-JSON response. Treat anything without a presses[] array // "successful" non-JSON response. Treat anything without a presses[] array
@ -126,6 +135,7 @@ export const useTemperOverviewStore = defineStore('temperOverview', () => {
return { return {
payload, payload,
range, range,
embedded,
loading, loading,
error, error,
usingSample, usingSample,

View File

@ -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()
@ -39,6 +39,7 @@ onUnmounted(() => store.stopPolling())
:range="range" :range="range"
:sample="usingSample" :sample="usingSample"
:disconnected="disconnected" :disconnected="disconnected"
:embedded="embedded"
@update:range="store.setRange" @update:range="store.setRange"
/> />
<FlowLegend /> <FlowLegend />