SPA_AfterTemperRejection/src/stores/temperRejects.ts
Erik 5f8bbe18a4 feat: Grafana-embed mode — hide native picker, follow Grafana time range
With ?embed=grafana on the URL the terminal hides its in-page window picker and drives the listed-carts 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 window) is untouched. Includes rebuilt dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:14:15 +02:00

230 lines
8.5 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
import api from '@/api'
import { sampleStation, SAMPLE_REASONS } from '@/data/sample'
import { isGrafanaEmbed, grafanaWindow } from '@/embed'
import type { StationPayload, Reason, RejectRequest, RejectResult, WindowCode } from '@/types/station'
// Same convention as PressV: loadCache() → hydrate() → refresh() → startPolling().
//
// Connection loss on this LIVE terminal: never fabricate carts. A failed fetch keeps
// the last known REAL payload (stale > fake — an operator must never book against a
// demo cart) and flips `disconnected`, so the UI shows a reconnect spinner and polling
// retries faster. Bundled sample data renders ONLY under `npm run dev` (no gateway) —
// gated behind `import.meta.env.DEV`, so `vite build` strips it from production.
const CACHE_KEY = 'temper_rejects_payload'
export const useTemperRejectsStore = defineStore('temperRejects', () => {
const payload = ref<StationPayload | null>(null)
const reasons = ref<Reason[]>([])
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 loading = ref(false)
const error = ref<string | null>(null)
const usingSample = ref(false)
const disconnected = ref(false)
const lastUpdated = ref<number | null>(null)
let timer: ReturnType<typeof setTimeout> | null = null
let pollMs = 20000
const RETRY_MS = 5000 // faster cadence while disconnected, to recover quickly
let filterInitialized = false
const hasData = computed(() => payload.value !== null)
const allPresses = computed(() => payload.value?.presses ?? [])
// entries per press (over ALL entries) — for the sidebar hit-count badges.
const pressCounts = computed<Record<string, number>>(() => {
const m: Record<string, number> = {}
for (const p of allPresses.value) m[p] = 0
for (const e of payload.value?.entries ?? []) m[e.press] = (m[e.press] ?? 0) + 1
return m
})
const filteredEntries = computed(() => {
const checked = new Set(checkedPresses.value)
return (payload.value?.entries ?? []).filter((e) => checked.has(e.press))
})
function saveCache() {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ payload: payload.value, window: window.value, checkedPresses: checkedPresses.value }))
} catch {
/* best-effort */
}
}
function loadCache() {
try {
const raw = sessionStorage.getItem(CACHE_KEY)
if (!raw) return
const blob = JSON.parse(raw) as { payload: StationPayload | null; window: WindowCode; checkedPresses: string[] }
payload.value = blob.payload ?? null
if (blob.window) window.value = blob.window
if (blob.checkedPresses?.length) {
checkedPresses.value = blob.checkedPresses
filterInitialized = true
}
} catch {
/* corrupt cache — ignore */
}
}
async function hydrate() {
if (!hasData.value) loading.value = true
await Promise.all([refresh(), fetchReasons()])
}
async function refresh() {
error.value = null
try {
// Grafana embed → exact from→to window; standalone → the preset window.
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)) {
throw new Error('Unexpected /overview response (no backend?)')
}
payload.value = res.data
usingSample.value = res.data.sample === true
disconnected.value = false
} catch (e) {
// Connection lost. Keep the last known REAL data (stale > fabricated): never
// replace live carts with demo carts on a live terminal, or an operator could
// book against a fabricated entry. Flag `disconnected` so the UI shows a
// reconnect spinner (cold start) or a header indicator (stale data) and retries.
disconnected.value = true
error.value = e instanceof Error ? e.message : 'Overview API unreachable'
// Sample data is for `npm run dev` only (no gateway) — `vite build` strips this.
if (import.meta.env.DEV && (!payload.value || usingSample.value)) {
payload.value = sampleStation(window.value)
usingSample.value = true
}
} finally {
// Default the filter to ALL presses on first load (Alle). "Keine" stays a
// valid explicit state afterwards, so only auto-fill once.
if (!filterInitialized && payload.value) {
checkedPresses.value = [...payload.value.presses]
filterInitialized = true
}
loading.value = false
lastUpdated.value = Date.now()
saveCache()
}
}
async function fetchReasons() {
try {
const res = await api.get<{ reasons: Reason[] }>('/reasons')
if (Array.isArray(res.data?.reasons) && res.data.reasons.length) {
reasons.value = res.data.reasons
return
}
throw new Error('no reasons')
} catch {
// Keep any reasons already loaded; only seed the bundled sample reasons under
// `npm run dev` — production must never offer fabricated reason codes.
if (import.meta.env.DEV && !reasons.value.length) reasons.value = SAMPLE_REASONS
}
}
async function setWindow(code: WindowCode) {
window.value = code
await refresh()
}
// ── press filter ────────────────────────────────────────────────────────────
function togglePress(press: string) {
const i = checkedPresses.value.indexOf(press)
if (i >= 0) checkedPresses.value.splice(i, 1)
else checkedPresses.value.push(press)
saveCache()
}
function selectAllPresses() {
checkedPresses.value = [...allPresses.value]
saveCache()
}
function selectNoPresses() {
checkedPresses.value = []
saveCache()
}
/** Book one reject. Returns the gateway's own {success} verdict. NEVER fakes success:
* on this live, login-free terminal an unconfirmed write must not read as booked. */
async function submitReject(req: RejectRequest): Promise<RejectResult> {
try {
const res = await api.post<RejectResult>('/reject', req)
if (res.data && typeof res.data === 'object' && 'success' in res.data) {
if (res.data.success) await refresh()
return res.data
}
// Reachable but unexpected shape (e.g. a dev SPA fallback serving index.html) →
// treat as NOT booked.
return { success: false, error: 'bad_response' }
} catch (e) {
// The gateway returns HTTP 200 + {success:false} for refusals/write failures, so a
// throw here is a genuine transport failure (backend unreachable / timeout) or a
// non-2xx carrying a body. Prefer the server's own {success:false}; otherwise report
// a transport failure. Either way, do NOT report success for an unconfirmed write.
const data = axios.isAxiosError(e) ? (e.response?.data as RejectResult | undefined) : undefined
if (data && typeof data === 'object' && 'success' in data) return data
return { success: false, error: 'unreachable' }
}
}
// Self-scheduling poll: next tick is the normal cadence when connected, a faster
// retry while disconnected so the terminal recovers as soon as the gateway is back.
function scheduleNext() {
timer = setTimeout(async () => {
await refresh()
scheduleNext()
}, disconnected.value ? RETRY_MS : pollMs)
}
function startPolling(ms = 20000) {
if (timer) return
pollMs = ms
scheduleNext()
}
function stopPolling() {
if (timer) {
clearTimeout(timer)
timer = null
}
}
loadCache()
return {
payload,
reasons,
window,
embedded,
checkedPresses,
loading,
error,
usingSample,
disconnected,
lastUpdated,
hasData,
allPresses,
pressCounts,
filteredEntries,
hydrate,
refresh,
fetchReasons,
setWindow,
togglePress,
selectAllPresses,
selectNoPresses,
submitReject,
startPolling,
stopPolling,
}
})