diff --git a/package.json b/package.json index fb8ba2b..ac5ec02 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@tauri-apps/api": "^1.6.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-markdown": "^9.0.1", "socket.io-client": "^4.8.3" }, "devDependencies": { diff --git a/src/components/PostToElogDialog.tsx b/src/components/PostToElogDialog.tsx new file mode 100644 index 0000000..ef0a978 --- /dev/null +++ b/src/components/PostToElogDialog.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + OutlinedInput, + Select, + Stack, + Tab, + Tabs, + TextField, + Typography, +} from '@mui/material'; +import ReactMarkdown from 'react-markdown'; + +import { PV, Snapshot } from '../types'; +import { elogService } from '../services/elogService'; +import { ElogTag, ElogLogbook, ElogConfigDTO } from '../types/elog'; +import { buildSnapshotElogBody } from '../utils/elogBody'; + +interface PostToElogDialogProps { + open: boolean; + snapshot: Snapshot; + selectedPVs: PV[]; + config: ElogConfigDTO; + onClose: () => void; + onPosted?: (entryId: string) => void; +} + +export function PostToElogDialog({ + open, + snapshot, + selectedPVs, + config, + onClose, + onPosted, +}: PostToElogDialogProps) { + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [logbooks, setLogbooks] = useState([]); + const [selectedLogbookIds, setSelectedLogbookIds] = useState([]); + const [tags, setTags] = useState([]); + const [selectedTagIds, setSelectedTagIds] = useState([]); + const [tab, setTab] = useState<'edit' | 'preview'>('edit'); + const [loadingMeta, setLoadingMeta] = useState(false); + const [posting, setPosting] = useState(false); + const [error, setError] = useState(null); + + // Reset form state every time the dialog opens. + useEffect(() => { + if (!open) return; + setTitle(`Snapshot: ${snapshot.title}`); + setBody(buildSnapshotElogBody(snapshot, selectedPVs)); + setSelectedLogbookIds(config.defaultLogbooks ?? []); + setSelectedTagIds([]); + setTab('edit'); + setError(null); + }, [open, snapshot, selectedPVs, config.defaultLogbooks]); + + // Fetch logbooks once per open. + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoadingMeta(true); + elogService + .listLogbooks() + .then((items) => { + if (cancelled) return; + setLogbooks(items); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : 'Failed to load logbooks'); + }) + .finally(() => { + if (!cancelled) setLoadingMeta(false); + }); + return () => { + cancelled = true; + }; + }, [open]); + + // Re-fetch tags whenever the first selected logbook changes. + useEffect(() => { + if (!open || selectedLogbookIds.length === 0) { + setTags([]); + return undefined; + } + const [primaryLogbook] = selectedLogbookIds; + let cancelled = false; + elogService + .listTags(primaryLogbook) + .then((items) => { + if (!cancelled) setTags(items); + }) + .catch(() => { + if (!cancelled) setTags([]); + }); + return () => { + cancelled = true; + }; + }, [open, selectedLogbookIds]); + + const logbookLookup = useMemo(() => { + const map: Record = {}; + logbooks.forEach((lb) => { + map[lb.id] = lb.name; + }); + return map; + }, [logbooks]); + + const tagLookup = useMemo(() => { + const map: Record = {}; + tags.forEach((t) => { + map[t.id] = t.name; + }); + return map; + }, [tags]); + + const canPost = + !posting && title.trim().length > 0 && body.length > 0 && selectedLogbookIds.length > 0; + + const handlePost = async () => { + setPosting(true); + setError(null); + try { + const result = await elogService.createEntry({ + logbooks: selectedLogbookIds, + title: title.trim(), + bodyMarkdown: body, + tags: selectedTagIds, + snapshotId: snapshot.uuid, + }); + onPosted?.(result.id); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to post entry'); + } finally { + setPosting(false); + } + }; + + return ( + + Post to Elog + + + {error && {error}} + + + Logbooks + + + + {tags.length > 0 && ( + + Tags + + + )} + + setTitle(e.target.value)} + fullWidth + size="small" + required + inputProps={{ maxLength: 255 }} + /> + + + setTab(v)} + sx={{ minHeight: 36, mb: 1 }} + > + + + + {tab === 'edit' ? ( + setBody(e.target.value)} + fullWidth + multiline + rows={16} + size="small" + placeholder="Write the entry body in markdown..." + InputProps={{ sx: { fontFamily: 'monospace', fontSize: 13 } }} + /> + ) : ( + + {body} + + )} + + + + Posting as the API key owner. Your name will be recorded as the entry author. + + + + + + + + + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index d22abbe..5bc6dda 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,3 +14,4 @@ export * from './CreateSnapshotDialog'; export * from './VirtualTable'; export * from './LiveDataWarningBanner'; export * from './TagGroupSelect'; +export * from './PostToElogDialog'; diff --git a/src/config/api.ts b/src/config/api.ts index d71a8a7..9c791b5 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -60,15 +60,6 @@ export function getWebSocketURL(path: string): string { return `${protocol}//${window.location.host}${path}`; } -/** - * Generic API response wrapper from squirrel-backend - */ -export interface ApiResultResponse { - errorCode: number; - errorMessage: string | null; - payload: T; -} - /** * Paged result wrapper for paginated endpoints */ diff --git a/src/contexts/LivePVContext.tsx b/src/contexts/LivePVContext.tsx index 99575c6..5792708 100644 --- a/src/contexts/LivePVContext.tsx +++ b/src/contexts/LivePVContext.tsx @@ -64,40 +64,36 @@ export function LivePVProvider({ throw new Error(`HTTP ${response.status}`); } - const data = await response.json(); - - if (data.errorCode === 0 && data.payload) { - const rawValues = data.payload as Record< - string, - { - value: unknown; - connected: boolean; - updated_at: number; - status?: string; - severity?: number; - units?: string; - } - >; - - // Transform backend format to EpicsData format - setLiveValues((prev) => { - const next = new Map(prev); - Object.entries(rawValues).forEach(([pvName, rawValue]) => { - // Map backend fields to EpicsData fields - const epicsData: EpicsData = { - data: rawValue.value as EpicsData['data'], - severity: rawValue.severity, - units: rawValue.units, - timestamp: rawValue.updated_at ? new Date(rawValue.updated_at * 1000) : undefined, - }; - next.set(pvName, epicsData); - }); - return next; + const rawValues = (await response.json()) as Record< + string, + { + value: unknown; + connected: boolean; + updated_at: number; + status?: string; + severity?: number; + units?: string; + } + >; + + // Transform backend format to EpicsData format + setLiveValues((prev) => { + const next = new Map(prev); + Object.entries(rawValues).forEach(([pvName, rawValue]) => { + // Map backend fields to EpicsData fields + const epicsData: EpicsData = { + data: rawValue.value as EpicsData['data'], + severity: rawValue.severity, + units: rawValue.units, + timestamp: rawValue.updated_at ? new Date(rawValue.updated_at * 1000) : undefined, + }; + next.set(pvName, epicsData); }); - setLastUpdate(new Date()); - setIsConnected(true); - setError(null); - } + return next; + }); + setLastUpdate(new Date()); + setIsConnected(true); + setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch live values'); setIsConnected(false); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e59b2eb..c76d512 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -11,5 +11,7 @@ export { type PVUpdate, } from './useBufferedLiveData'; +export { useElogConfig } from './useElogConfig'; + // Re-export query hooks export * from './queries'; diff --git a/src/hooks/useElogConfig.ts b/src/hooks/useElogConfig.ts new file mode 100644 index 0000000..71a54c5 --- /dev/null +++ b/src/hooks/useElogConfig.ts @@ -0,0 +1,42 @@ +/** + * Fetches the e-log feature-flag config once per app load. + * + * Used to hide the "Post to Elog" button when no adapter is configured on + * the backend. + */ +import { useState, useEffect } from 'react'; + +import { elogService } from '../services/elogService'; +import { ElogConfigDTO } from '../types/elog'; + +let cache: Promise | null = null; + +function fetchConfig(): Promise { + if (!cache) { + cache = elogService.getConfig().catch((err) => { + // Reset the cache so retries on failure are possible; fall back to + // disabled so the UI stays usable if the endpoint is unreachable. + cache = null; + // eslint-disable-next-line no-console + console.warn('Failed to load e-log config:', err); + return { enabled: false, provider: '', defaultLogbooks: [] }; + }); + } + return cache; +} + +export function useElogConfig(): ElogConfigDTO | null { + const [config, setConfig] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchConfig().then((c) => { + if (!cancelled) setConfig(c); + }); + return () => { + cancelled = true; + }; + }, []); + + return config; +} diff --git a/src/pages/SnapshotDetailsPage.tsx b/src/pages/SnapshotDetailsPage.tsx index a13f401..7d4d690 100644 --- a/src/pages/SnapshotDetailsPage.tsx +++ b/src/pages/SnapshotDetailsPage.tsx @@ -16,10 +16,17 @@ import { ListItemButton, ListItemText as MuiListItemText, } from '@mui/material'; -import { Restore, Add } from '@mui/icons-material'; +import { Restore, Add, Article } from '@mui/icons-material'; import { Snapshot, PV } from '../types'; -import { SnapshotHeader, SearchBar, PVTable, TagGroupSelect } from '../components'; +import { + SnapshotHeader, + SearchBar, + PVTable, + TagGroupSelect, + PostToElogDialog, +} from '../components'; import { tagsService, snapshotService } from '../services'; +import { useElogConfig } from '../hooks'; import { SnapshotSummaryDTO } from '../types/api'; interface SnapshotDetailsPageProps { @@ -46,6 +53,8 @@ export function SnapshotDetailsPage({ const [availableSnapshots, setAvailableSnapshots] = useState([]); const [loadingSnapshots, setLoadingSnapshots] = useState(false); const [showOnlySelected, setShowOnlySelected] = useState(false); + const [showElogDialog, setShowElogDialog] = useState(false); + const elogConfig = useElogConfig(); // Fetch tag groups when component mounts useEffect(() => { @@ -253,6 +262,16 @@ export function SnapshotDetailsPage({ + {elogConfig?.enabled && ( + + )} @@ -348,6 +367,16 @@ export function SnapshotDetailsPage({ + + {elogConfig?.enabled && ( + 0 ? selectedPVs : snapshot.pvs} + config={elogConfig} + onClose={() => setShowElogDialog(false)} + /> + )} ); } diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 9436ff8..bc2bd26 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -2,7 +2,41 @@ * Base API client for making HTTP requests to squirrel-backend */ -import { API_CONFIG, ApiKeyError, ApiResultResponse } from '../config/api'; +import { API_CONFIG, ApiKeyError } from '../config/api'; + +interface ValidationErrorItem { + loc?: (string | number)[]; + msg: string; + type?: string; +} + +async function extractErrorMessage(response: Response): Promise { + const fallback = `HTTP error! status: ${response.status}`; + let body: string; + try { + body = await response.text(); + } catch { + return fallback; + } + if (!body) return fallback; + + try { + const parsed = JSON.parse(body); + const { detail } = parsed; + if (typeof detail === 'string') return detail; + if (Array.isArray(detail)) { + return (detail as ValidationErrorItem[]) + .map((d) => `${d.loc?.join('.') ?? ''}: ${d.msg}`) + .join('; '); + } + } catch { + // not JSON — fall through + } + + // eslint-disable-next-line no-console + console.error('Server error response:', body); + return `${fallback}, details: ${body}`; +} class APIClient { // eslint-disable-next-line class-methods-use-this @@ -28,24 +62,14 @@ class APIClient { if (response.status === 401 || response.status === 403) { throw new ApiKeyError(response.status); } - // Try to get error details from response body - try { - const errorData = await response.text(); - // eslint-disable-next-line no-console - console.error('Server error response:', errorData); - throw new Error(`HTTP error! status: ${response.status}, details: ${errorData}`); - } catch { - throw new Error(`HTTP error! status: ${response.status}`); - } + throw new Error(await extractErrorMessage(response)); } - const data: ApiResultResponse = await response.json(); - - if (data.errorCode !== 0) { - throw new Error(data.errorMessage || 'API error'); + if (response.status === 204 || response.headers.get('content-length') === '0') { + return undefined as T; } - return data.payload; + return (await response.json()) as T; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { diff --git a/src/services/elogService.ts b/src/services/elogService.ts new file mode 100644 index 0000000..41e81fe --- /dev/null +++ b/src/services/elogService.ts @@ -0,0 +1,32 @@ +/** + * API service for posting snapshots to an e-log. + */ + +import { apiClient } from './apiClient'; +import { + ElogTag, + ElogLogbook, + ElogConfigDTO, + ElogEntryResult, + CreateElogEntryRequest, +} from '../types/elog'; + +const BASE = '/v1/elog'; + +export const elogService = { + async getConfig(): Promise { + return apiClient.get(`${BASE}/config`); + }, + + async listLogbooks(): Promise { + return apiClient.get(`${BASE}/logbooks`); + }, + + async listTags(logbookId: string): Promise { + return apiClient.get(`${BASE}/logbooks/${encodeURIComponent(logbookId)}/tags`); + }, + + async createEntry(request: CreateElogEntryRequest): Promise { + return apiClient.post(`${BASE}/entries`, request); + }, +}; diff --git a/src/services/heartbeatService.ts b/src/services/heartbeatService.ts index bc54beb..0c88d69 100644 --- a/src/services/heartbeatService.ts +++ b/src/services/heartbeatService.ts @@ -65,9 +65,9 @@ class HeartbeatService { const data = await response.json(); // Backend returns: { alive: boolean, timestamp: number | null, age_seconds: number | null } - const alive = data.payload?.alive ?? false; - const age = data.payload?.age_seconds ?? null; - const timestamp = data.payload?.timestamp ?? null; + const alive = data.alive ?? false; + const age = data.age_seconds ?? null; + const timestamp = data.timestamp ?? null; this.lastKnownState = { alive, age, timestamp }; this.notifyCallbacks(alive, age); diff --git a/src/services/index.ts b/src/services/index.ts index 99d8ce3..b1a547e 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -8,3 +8,4 @@ export * from './pvService'; export * from './tagsService'; export * from './jobService'; export * from './websocketService'; +export * from './elogService'; diff --git a/src/types/elog.ts b/src/types/elog.ts new file mode 100644 index 0000000..d2c9a4a --- /dev/null +++ b/src/types/elog.ts @@ -0,0 +1,32 @@ +/** + * Types for the e-log integration endpoints (/v1/elog/*). + */ + +export interface ElogConfigDTO { + enabled: boolean; + provider: string; + defaultLogbooks: string[]; +} + +export interface ElogLogbook { + id: string; + name: string; +} + +export interface ElogTag { + id: string; + name: string; +} + +export interface CreateElogEntryRequest { + logbooks: string[]; + title: string; + bodyMarkdown: string; + tags: string[]; + snapshotId?: string; +} + +export interface ElogEntryResult { + id: string; + url?: string | null; +} diff --git a/src/utils/elogBody.ts b/src/utils/elogBody.ts new file mode 100644 index 0000000..9f03d0c --- /dev/null +++ b/src/utils/elogBody.ts @@ -0,0 +1,62 @@ +/** + * Builds the pre-filled markdown body posted to the e-log when a user + * opens the "Post to Elog" dialog from a snapshot. + */ +import { PV, Snapshot } from '../types'; + +const MAX_ROWS = 200; + +function fmtValue(v: unknown): string { + if (v === undefined || v === null) return '—'; + if (typeof v === 'number') return Number.isInteger(v) ? String(v) : v.toFixed(4); + return String(v); +} + +function escapeCell(s: string): string { + return s.replace(/\|/g, '\\|').replace(/\n/g, ' '); +} + +export function buildSnapshotElogBody(snapshot: Snapshot, selectedPVs: PV[]): string { + const header = [ + `# ${snapshot.title}`, + '', + snapshot.description?.trim() ? snapshot.description.trim() : '', + '', + `**Snapshot ID:** \`${snapshot.uuid}\``, + `**Created:** ${new Date(snapshot.creation_time).toLocaleString()}`, + `**Total PVs:** ${snapshot.pvCount ?? snapshot.pvs.length} · **Selected:** ${selectedPVs.length}`, + '', + ] + .filter((line, idx, arr) => !(line === '' && arr[idx - 1] === '')) + .join('\n'); + + if (selectedPVs.length === 0) { + return `${header}\n_No PVs selected._\n`; + } + + const rows = selectedPVs.slice(0, MAX_ROWS).map((pv) => { + const setpoint = pv.setpoint || ''; + const readback = pv.readback || ''; + const spVal = fmtValue(pv.setpoint_data?.value); + const rbVal = fmtValue(pv.readback_data?.value); + return `| ${escapeCell(setpoint)} | ${escapeCell(readback)} | ${escapeCell(spVal)} | ${escapeCell(rbVal)} |`; + }); + + const truncatedNote = + selectedPVs.length > MAX_ROWS + ? `\n\n_Showing first ${MAX_ROWS} of ${selectedPVs.length} selected PVs._` + : ''; + + return [ + header, + '## Selected values', + '', + '| Setpoint PV | Readback PV | Setpoint value | Readback value |', + '|---|---|---|---|', + ...rows, + truncatedNote, + ] + .join('\n') + .trimEnd() + .concat('\n'); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 9b0aefb..f164aff 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from './csvParser'; +export * from './elogBody';