diff --git a/.github/workflows/build-and-deploy-dev.yml b/.github/workflows/build-and-deploy-dev.yml new file mode 100644 index 0000000..a0043ae --- /dev/null +++ b/.github/workflows/build-and-deploy-dev.yml @@ -0,0 +1,101 @@ +name: Build and Deploy to Dev + +on: + push: + branches: + - main + +permissions: + contents: write + deployments: write + actions: read + +jobs: + build: + runs-on: ubuntu-22.04 + outputs: + rpm_name: ${{ steps.find-rpm.outputs.rpm_name }} + image_tag: ${{ steps.vars.outputs.image_tag }} + steps: + - name: Checkout codebase + uses: actions/checkout@v4 + + - name: Set image tag + id: vars + run: echo "image_tag=dev-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install pnpm dependencies with caching + uses: actions/setup-node@v4 + with: + node-version: 23.11.0 + cache: pnpm + - run: pnpm install + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.93.1 + + - name: Install dependencies (ubuntu only) + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev + + - name: Install Tauri + run: pnpm add -D @tauri-apps/cli@1 + + - name: Configure Tauri + run: | + pnpm tauri init \ + --app-name "Squirrel" \ + --window-title "Squirrel" \ + --dist-dir ../dist \ + --dev-path http://localhost:5173 \ + --before-dev-command "pnpm dev" \ + --before-build-command "pnpm build" + + - name: Build Tauri package + id: build + uses: tauri-apps/tauri-action@v0.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: >- + --bundles rpm + --config {"tauri":{"bundle":{"identifier":"edu.stanford.slac.squirrel"},"allowlist":{"fs":{"readFile":true,"scope":["$RESOURCE/*"]},"path":{"all":true}}}} + + - name: Find RPM file + id: find-rpm + run: | + RPM_PATH=$(ls src-tauri/target/release/bundle/rpm/*.rpm) + RPM_NAME=$(basename "$RPM_PATH") + echo "rpm_name=$RPM_NAME" >> $GITHUB_OUTPUT + echo "rpm_path=$RPM_PATH" >> $GITHUB_OUTPUT + echo "Found RPM: $RPM_NAME" + + - name: Create/update dev-latest pre-release + uses: softprops/action-gh-release@v2 + with: + tag_name: dev-latest + name: Dev Latest + prerelease: true + files: ${{ steps.find-rpm.outputs.rpm_path }} + body: | + Rolling dev build from main branch. + Commit: ${{ github.sha }} + Built: ${{ github.event.head_commit.timestamp }} + + deploy: + needs: build + uses: ad-build-test/build-system-playbooks/.github/workflows/request-deployment.yml@main + with: + deploy_to_dev: true + tag: ${{ needs.build.outputs.image_tag }} + deployment_type: app + artifact_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/dev-latest/${{ needs.build.outputs.rpm_name }} + artifact_type: rpm diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..2f0eaae --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,65 @@ +name: Build Release Artifact + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-22.04 + steps: + - name: Checkout codebase + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install pnpm dependencies with caching + uses: actions/setup-node@v4 + with: + node-version: 23.11.0 + cache: pnpm + - run: pnpm install + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.93.1 + + - name: Install dependencies (ubuntu only) + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev + + - name: Install Tauri + run: pnpm add -D @tauri-apps/cli@1 + + - name: Configure Tauri + run: | + pnpm tauri init \ + --app-name "Squirrel" \ + --window-title "Squirrel" \ + --dist-dir ../dist \ + --dev-path http://localhost:5173 \ + --before-dev-command "pnpm dev" \ + --before-build-command "pnpm build" + + - name: Build Tauri package + id: build + uses: tauri-apps/tauri-action@v0.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: >- + --bundles rpm + --config {"tauri":{"bundle":{"identifier":"edu.stanford.slac.squirrel"},"allowlist":{"fs":{"readFile":true,"scope":["$RESOURCE/*"]},"path":{"all":true}}}} + + - name: Attach RPM to release + uses: softprops/action-gh-release@v2 + with: + files: src-tauri/target/release/bundle/rpm/*.rpm diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..1650a65 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,60 @@ +name: Deploy to Production + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag to deploy (e.g., v1.0.0)' + required: true + type: string + deploy_to_lcls: + description: 'Deploy to LCLS' + required: false + type: boolean + default: true + deploy_to_facet: + description: 'Deploy to FACET' + required: false + type: boolean + default: false + deploy_to_testfac: + description: 'Deploy to TESTFAC' + required: false + type: boolean + default: false + +permissions: + deployments: write + contents: read + actions: read + +jobs: + find-rpm: + runs-on: ubuntu-latest + outputs: + rpm_name: ${{ steps.find.outputs.rpm_name }} + steps: + - name: Find RPM asset in release + id: find + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + RPM_NAME=$(gh release view "${{ inputs.release_tag }}" --repo "${{ github.repository }}" --json assets --jq '.assets[] | select(.name | endswith(".rpm")) | .name') + if [ -z "$RPM_NAME" ]; then + echo "Error: No RPM found in release ${{ inputs.release_tag }}" + exit 1 + fi + echo "rpm_name=$RPM_NAME" >> $GITHUB_OUTPUT + echo "Found RPM: $RPM_NAME" + + deploy: + needs: find-rpm + uses: ad-build-test/build-system-playbooks/.github/workflows/request-deployment.yml@main + with: + deploy_to_lcls: ${{ inputs.deploy_to_lcls }} + deploy_to_facet: ${{ inputs.deploy_to_facet }} + deploy_to_testfac: ${{ inputs.deploy_to_testfac }} + tag: ${{ inputs.release_tag }} + deployment_type: app + artifact_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ inputs.release_tag }}/${{ needs.find-rpm.outputs.rpm_name }} + artifact_type: rpm diff --git a/.github/workflows/tauri.yml b/.github/workflows/tauri.yml index aa7d0a2..b085017 100644 --- a/.github/workflows/tauri.yml +++ b/.github/workflows/tauri.yml @@ -58,6 +58,9 @@ jobs: --before-dev-command "pnpm dev" \ --before-build-command "pnpm build" + - name: Pin tauri crate to version with fixed wry/webkit2gtk compatibility + run: sed -i 's/^tauri = .*/tauri = { version = "1.8", features = ["shell-open"] }/' src-tauri/Cargo.toml + - name: Build Tauri package id: build uses: tauri-apps/tauri-action@v0.6.1 @@ -66,7 +69,7 @@ jobs: with: args: >- ${{ matrix.args }} - --config {"tauri":{"bundle":{"identifier":"edu.stanford.slac.squirrel"}}} + --config {"tauri":{"bundle":{"identifier":"edu.stanford.slac.squirrel"},"allowlist":{"shell":{"open":true}}}} - name: Upload Tauri artifacts uses: actions/upload-artifact@v6 diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..dc9b621 --- /dev/null +++ b/config.json.example @@ -0,0 +1,3 @@ +{ + "apiUrl": "http://localhost:8080" +} diff --git a/package.json b/package.json index 383b669..fb8ba2b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@tanstack/react-router": "^1.168.3", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.23", + "@tauri-apps/api": "^1.6.0", "react": "^19.2.4", "react-dom": "^19.2.4", "socket.io-client": "^4.8.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d03e3b..3283d61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tauri-apps/api': + specifier: ^1.6.0 + version: 1.6.0 react: specifier: ^19.2.4 version: 19.2.4 @@ -800,6 +803,10 @@ packages: engines: {node: '>=20.19'} hasBin: true + '@tauri-apps/api@1.6.0': + resolution: {integrity: sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg==} + engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3151,6 +3158,8 @@ snapshots: '@tanstack/virtual-file-routes@1.161.7': {} + '@tauri-apps/api@1.6.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 7e18348..f302693 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -29,12 +29,20 @@ export function Layout({ children }: LayoutProps) { setSnapshotDialogOpen(true); }; + const openExternal = (url: string) => { + if ('__TAURI__' in window) { + import('@tauri-apps/api/shell').then(({ open }) => open(url)); + } else { + window.open(url, '_blank'); + } + }; + const handleHelpClick = () => { - window.open('https://github.com/slaclab/squirrel', '_blank'); + openExternal('https://slaclab.github.io/react-squirrel/'); }; const handleBugReportClick = () => { - window.open('https://forms.office.com/r/A6p1TmFNw3', '_blank'); + openExternal('https://forms.office.com/r/A6p1TmFNw3'); }; return ( diff --git a/src/config/api.ts b/src/config/api.ts index 22bedd6..d71a8a7 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -1,12 +1,41 @@ /** - * API configuration for score-backend - * Based on configuration from superscore/squirrel-local.cfg + * API configuration for squirrel-backend * - * Note: baseURL is empty because Vite proxy handles forwarding /v1/* to http://localhost:8080 + * In dev mode, Vite proxy forwards /v1/* to http://localhost:8080. + * In production (Tauri), config.json is read at startup to determine the backend URL. + * Falls back to VITE_API_URL env var if config.json is not available. */ +let runtimeBaseURL: string | null = null; + +/** + * Load runtime configuration from config.json via Tauri FS API. + * Must be called before the app renders. No-ops in non-Tauri environments. + */ +export async function loadRuntimeConfig(): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-underscore-dangle + if (!(window as any).__TAURI__) return; + + try { + const { resolveResource } = await import('@tauri-apps/api/path'); + const { readTextFile } = await import('@tauri-apps/api/fs'); + + const configPath = await resolveResource('config.json'); + const text = await readTextFile(configPath); + const config = JSON.parse(text); + if (config.apiUrl) { + runtimeBaseURL = config.apiUrl; + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not load config.json, using build-time fallback'); + } +} + export const API_CONFIG = { - baseURL: '', // Empty to use Vite proxy + get baseURL(): string { + return runtimeBaseURL || import.meta.env.VITE_API_URL || ''; + }, endpoints: { snapshots: '/v1/snapshots', pvs: '/v1/pvs', @@ -18,7 +47,21 @@ export const API_CONFIG = { }; /** - * Generic API response wrapper from score-backend + * Derive a WebSocket URL from the configured base URL, or fall back to + * the current page host (for Vite proxy in dev mode). + */ +export function getWebSocketURL(path: string): string { + if (API_CONFIG.baseURL) { + const url = new URL(API_CONFIG.baseURL); + const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${url.host}${path}`; + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}${path}`; +} + +/** + * Generic API response wrapper from squirrel-backend */ export interface ApiResultResponse { errorCode: number; diff --git a/src/contexts/LivePVContext.tsx b/src/contexts/LivePVContext.tsx index c9025cf..99575c6 100644 --- a/src/contexts/LivePVContext.tsx +++ b/src/contexts/LivePVContext.tsx @@ -52,7 +52,7 @@ export function LivePVProvider({ try { // Use POST to avoid URL length limits with many PVs - const response = await fetch(`${API_CONFIG.endpoints.pvs}/live`, { + const response = await fetch(`${API_CONFIG.baseURL}${API_CONFIG.endpoints.pvs}/live`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/hooks/useBufferedLiveData.ts b/src/hooks/useBufferedLiveData.ts index 02e1808..099a9cb 100644 --- a/src/hooks/useBufferedLiveData.ts +++ b/src/hooks/useBufferedLiveData.ts @@ -10,6 +10,7 @@ */ import { useRef, useEffect, useCallback, useState, useMemo } from 'react'; +import { getWebSocketURL } from '../config/api'; export interface PVUpdate { value: unknown; @@ -62,11 +63,7 @@ export function useBufferedLiveData({ pvNames, enabled = true, }: UseBufferedLiveDataOptions): UseBufferedLiveDataReturn { - // Build WebSocket URL - const defaultWsUrl = useMemo(() => { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${protocol}//${window.location.host}/v1/ws/live`; - }, []); + const defaultWsUrl = useMemo(() => getWebSocketURL('/v1/ws/live'), []); const effectiveWsUrl = wsUrl ?? defaultWsUrl; diff --git a/src/main.tsx b/src/main.tsx index f379195..e86682c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import ReactDOM from 'react-dom/client'; import { RouterProvider, createRouter } from '@tanstack/react-router'; import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'; -import { ApiKeyError } from './config/api'; +import { ApiKeyError, loadRuntimeConfig } from './config/api'; import { HeartbeatProvider } from './contexts/HeartbeatContext'; import { LivePVProvider } from './contexts/LivePVContext'; import { SnapshotProvider } from './contexts/SnapshotContext'; @@ -64,10 +64,20 @@ function App() { ); } -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - -); +// Load runtime config (reads config.json in Tauri, no-ops in dev) then render. +// Errors inside loadRuntimeConfig are caught internally; the .catch here guards +// against unexpected failures so the app still renders with fallback config. +loadRuntimeConfig() + .catch(() => { + // eslint-disable-next-line no-console + console.warn('Runtime config loading failed unexpectedly, using defaults'); + }) + .then(() => { + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + ); + }); diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 31576ec..9436ff8 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -1,24 +1,16 @@ /** - * Base API client for making HTTP requests to score-backend + * Base API client for making HTTP requests to squirrel-backend */ import { API_CONFIG, ApiKeyError, ApiResultResponse } from '../config/api'; class APIClient { - private baseURL: string; - - private timeout: number; - - constructor() { - this.baseURL = API_CONFIG.baseURL; - this.timeout = API_CONFIG.timeout; - } - + // eslint-disable-next-line class-methods-use-this private async request(endpoint: string, options: RequestInit = {}): Promise { - const url = `${this.baseURL}${endpoint}`; + const url = `${API_CONFIG.baseURL}${endpoint}`; const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); + const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout); try { const response = await fetch(url, { diff --git a/src/services/heartbeatService.ts b/src/services/heartbeatService.ts index a2c60f6..bc54beb 100644 --- a/src/services/heartbeatService.ts +++ b/src/services/heartbeatService.ts @@ -5,6 +5,8 @@ * This catches the case where the monitor process dies silently. */ +import { API_CONFIG } from '../config/api'; + type HeartbeatCallback = (isAlive: boolean, ageSeconds: number | null) => void; interface HeartbeatState { @@ -54,7 +56,7 @@ class HeartbeatService { */ private async checkHeartbeat(): Promise { try { - const response = await fetch('/v1/health/heartbeat'); + const response = await fetch(`${API_CONFIG.baseURL}/v1/health/heartbeat`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); diff --git a/src/services/websocketService.ts b/src/services/websocketService.ts index aa88548..d056b99 100644 --- a/src/services/websocketService.ts +++ b/src/services/websocketService.ts @@ -4,6 +4,7 @@ */ import { EpicsData } from '../types'; +import { getWebSocketURL } from '../config/api'; type PVUpdateCallback = (pvName: string, value: EpicsData) => void; type ConnectionCallback = (connected: boolean) => void; @@ -208,9 +209,7 @@ class WebSocketService { // Private methods private static getDefaultWsUrl(): string { - // Use same host - Vite proxy will forward WebSocket connections - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${protocol}//${window.location.host}/v1/ws/pvs`; + return getWebSocketURL('/v1/ws/pvs'); } private sendSubscribe(pvNames: string[]): void { diff --git a/src/types/api.ts b/src/types/api.ts index a748d64..e8dd62c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,5 +1,5 @@ /** - * API type definitions matching score-backend DTOs + * API type definitions matching squirrel-backend DTOs */ /**