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>
230 lines
8.5 KiB
TypeScript
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,
|
|
}
|
|
})
|