Compare commits
No commits in common. "f410c1c22f8ef203f7dd2b91452749122d27baea" and "0696f1645823f1159133fa0bafac2592de4a303e" have entirely different histories.
f410c1c22f
...
0696f16458
@ -33,14 +33,6 @@ MODULE_NAME = "temper_rejects"
|
|||||||
# 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_rejects"
|
MODULE_PREFIX = "/module/temper_rejects"
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
@ -67,16 +67,6 @@ _PLANT = os.getenv("PLANT_NAME", "Cadolzburg")
|
|||||||
_WINDOW_MINUTES: dict[str, int] = {"12h": 720, "1T": 1440, "7T": 10080, "14T": 20160, "30T": 43200}
|
_WINDOW_MINUTES: dict[str, int] = {"12h": 720, "1T": 1440, "7T": 10080, "14T": 20160, "30T": 43200}
|
||||||
_DEFAULT_WINDOW = "7T"
|
_DEFAULT_WINDOW = "7T"
|
||||||
|
|
||||||
|
|
||||||
def _abs_window_label(from_ms: int, to_ms: int) -> str:
|
|
||||||
"""Compact human label for an absolute (Grafana) window, returned in `window`
|
|
||||||
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"
|
|
||||||
|
|
||||||
# Cap the listed carts so a wide window can't return an unbounded payload (the
|
# Cap the listed carts so a wide window can't return an unbounded payload (the
|
||||||
# frontend renders one card per entry). Newest-first, so the cap drops the oldest.
|
# frontend renders one card per entry). Newest-first, so the cap drops the oldest.
|
||||||
_MAX_ENTRIES = 500
|
_MAX_ENTRIES = 500
|
||||||
@ -413,36 +403,9 @@ class RejectBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/overview", summary="Tempered carts per ttId, newest first (live)")
|
@router.get("/overview", summary="Tempered carts per ttId, newest first (live)")
|
||||||
def overview(
|
def overview(window: str = Query(_DEFAULT_WINDOW, description="12h|1T|7T|14T|30T")) -> dict[str, Any]:
|
||||||
window: str = Query(_DEFAULT_WINDOW, description="12h|1T|7T|14T|30T"),
|
code = window if window in _WINDOW_MINUTES else _DEFAULT_WINDOW
|
||||||
from_ms: Optional[int] = Query(
|
limit_min = _WINDOW_MINUTES[code]
|
||||||
None, alias="from",
|
|
||||||
description="absolute window START, epoch ms (Grafana ${__from}). When paired "
|
|
||||||
"with `to`, overrides `window` with an exact from→to range.",
|
|
||||||
),
|
|
||||||
to_ms: Optional[int] = Query(
|
|
||||||
None, alias="to", description="absolute window END, epoch ms (Grafana ${__to}).",
|
|
||||||
),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
# Grafana embed passes an absolute [from,to] window (epoch ms); it wins over the
|
|
||||||
# preset `window`. FROM_UNIXTIME resolves in the same DB session clock as NOW(), so
|
|
||||||
# the timezone handling matches the preset path.
|
|
||||||
absolute = from_ms is not None and to_ms is not None and to_ms > from_ms
|
|
||||||
if absolute:
|
|
||||||
code = _abs_window_label(from_ms, to_ms)
|
|
||||||
bound_sql = (
|
|
||||||
" AND NOW() >= ttOutOfOvenAt "
|
|
||||||
" AND ttOutOfOvenAt >= FROM_UNIXTIME(:from_s) "
|
|
||||||
" AND ttOutOfOvenAt <= FROM_UNIXTIME(:to_s) "
|
|
||||||
)
|
|
||||||
bounds: dict[str, Any] = {"from_s": from_ms / 1000.0, "to_s": to_ms / 1000.0}
|
|
||||||
else:
|
|
||||||
code = window if window in _WINDOW_MINUTES else _DEFAULT_WINDOW
|
|
||||||
bound_sql = (
|
|
||||||
" AND NOW() >= ttOutOfOvenAt " # fertig getempert
|
|
||||||
" AND ttOutOfOvenAt >= (NOW() - INTERVAL :mins MINUTE) "
|
|
||||||
)
|
|
||||||
bounds = {"mins": _WINDOW_MINUTES[code]}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rows = run_select_query(
|
rows = run_select_query(
|
||||||
@ -452,10 +415,11 @@ def overview(
|
|||||||
"WHERE ttPlant = :plant "
|
"WHERE ttPlant = :plant "
|
||||||
" AND ttReprintOf IS NULL " # a reprint is not a new label
|
" AND ttReprintOf IS NULL " # a reprint is not a new label
|
||||||
" AND ttOutOfOvenAt IS NOT NULL "
|
" AND ttOutOfOvenAt IS NOT NULL "
|
||||||
+ bound_sql
|
" AND NOW() >= ttOutOfOvenAt " # fertig getempert
|
||||||
+ "ORDER BY ttOutOfOvenAt DESC, ttId DESC "
|
" AND ttOutOfOvenAt >= (NOW() - INTERVAL :mins MINUTE) "
|
||||||
|
"ORDER BY ttOutOfOvenAt DESC, ttId DESC "
|
||||||
"LIMIT :lim",
|
"LIMIT :lim",
|
||||||
{"plant": _PLANT, "lim": _MAX_ENTRIES + 1, **bounds},
|
{"plant": _PLANT, "mins": limit_min, "lim": _MAX_ENTRIES + 1},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("temper_rejects overview query failed: %s", e, exc_info=True)
|
logger.error("temper_rejects overview query failed: %s", e, exc_info=True)
|
||||||
|
|||||||
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>Ausschuss-Station</title>
|
<title>Ausschuss-Station</title>
|
||||||
<script type="module" crossorigin src="/module/temper_rejects/assets/index-E-o3DjeF.js"></script>
|
<script type="module" crossorigin src="/module/temper_rejects/assets/index-CjCewD1P.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-fYNT-3K2.css">
|
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-CU7fLO-K.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { WindowCode } from '@/types/station'
|
import type { WindowCode } from '@/types/station'
|
||||||
|
|
||||||
defineProps<{ window: WindowCode; sample: boolean; disconnected?: boolean; embedded?: boolean }>()
|
defineProps<{ window: WindowCode; sample: boolean; disconnected?: boolean }>()
|
||||||
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
|
const emit = defineEmits<{ (e: 'update:window', code: WindowCode): void }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -35,9 +35,7 @@ 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>
|
||||||
<!-- In a Grafana embed the window is driven by Grafana's own time picker, so the
|
<span class="rangep" role="group" :aria-label="t('window.label')">
|
||||||
native one is hidden to avoid two competing time controls. -->
|
|
||||||
<span v-if="!embedded" class="rangep" role="group" :aria-label="t('window.label')">
|
|
||||||
<span class="rl">{{ t('window.label') }}</span>
|
<span class="rl">{{ t('window.label') }}</span>
|
||||||
<button
|
<button
|
||||||
v-for="code in WINDOWS"
|
v-for="code in WINDOWS"
|
||||||
|
|||||||
37
src/embed.ts
37
src/embed.ts
@ -1,37 +0,0 @@
|
|||||||
// Grafana-embed detection + time range, read from the iframe URL.
|
|
||||||
//
|
|
||||||
// When this terminal 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
|
|
||||||
// window 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 window 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 }
|
|
||||||
}
|
|
||||||
@ -3,7 +3,6 @@ import { ref, computed } from 'vue'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
|
import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
|
||||||
import { isGrafanaEmbed, grafanaWindow } from '@/embed'
|
|
||||||
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
|
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
|
||||||
|
|
||||||
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
|
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
|
||||||
@ -20,12 +19,6 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
|||||||
const payload = ref<StationPayload | null>(null)
|
const payload = ref<StationPayload | null>(null)
|
||||||
const reasons = ref<Reason[]>([])
|
const reasons = ref<Reason[]>([])
|
||||||
const window = ref<WindowCode>('7T')
|
const window = ref<WindowCode>('7T')
|
||||||
// Grafana embed: when hosted in a Grafana iframe (?embed=grafana), the native window
|
|
||||||
// picker is hidden and the listed-carts window is driven by Grafana's own time range
|
|
||||||
// (from/to epoch ms) instead of the preset. 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 checkedPresses = ref<string[]>([]) // press filter (default = all)
|
const checkedPresses = ref<string[]>([]) // press filter (default = all)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
@ -85,9 +78,7 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
// Grafana embed → exact from→to window; standalone → the preset window.
|
const res = await api.get<StationPayload>('/overview', { params: { window: window.value } })
|
||||||
const params = grafanaWin ? { from: grafanaWin.from, to: grafanaWin.to } : { window: window.value }
|
|
||||||
const res = await api.get<StationPayload>('/overview', { params })
|
|
||||||
if (!res.data || !Array.isArray(res.data.entries)) {
|
if (!res.data || !Array.isArray(res.data.entries)) {
|
||||||
throw new Error('Unexpected /overview response (no backend?)')
|
throw new Error('Unexpected /overview response (no backend?)')
|
||||||
}
|
}
|
||||||
@ -204,7 +195,6 @@ export const useTemperRejectsStore = defineStore('temperRejects', () => {
|
|||||||
payload,
|
payload,
|
||||||
reasons,
|
reasons,
|
||||||
window,
|
window,
|
||||||
embedded,
|
|
||||||
checkedPresses,
|
checkedPresses,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import type { RejectEntry, RejectRequest } from '@/types/station'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useTemperRejectsStore()
|
const store = useTemperRejectsStore()
|
||||||
const { window, embedded, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample, hasData, disconnected } = storeToRefs(store)
|
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample, hasData, disconnected } = storeToRefs(store)
|
||||||
|
|
||||||
const pop = ref<InstanceType<typeof RejectPopover> | null>(null)
|
const pop = ref<InstanceType<typeof RejectPopover> | null>(null)
|
||||||
const bookingEntry = ref<RejectEntry | null>(null)
|
const bookingEntry = ref<RejectEntry | null>(null)
|
||||||
@ -82,7 +82,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<StationHeader :window="window" :sample="usingSample" :disconnected="disconnected" :embedded="embedded" @update:window="store.setWindow" />
|
<StationHeader :window="window" :sample="usingSample" :disconnected="disconnected" @update:window="store.setWindow" />
|
||||||
|
|
||||||
<!-- Cold start / no data yet: never blank, never fake — spinner that keeps retrying. -->
|
<!-- Cold start / no data yet: never blank, never fake — spinner that keeps retrying. -->
|
||||||
<div v-if="!hasData" class="state connect">
|
<div v-if="!hasData" class="state connect">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user