From 8517627b429cdc6485746c965e56689d5769d2d6 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 4 May 2025 19:10:21 +0000 Subject: [PATCH 1/2] feat: add app router and build info endpoint - Introduced a new router for application-related endpoints, including a `/build-info` endpoint to serve current build information from a generated JSON file. - Implemented a Vite plugin to generate `build-info.json` during the build process, containing a unique build hash and timestamp. - Added a `BuildVersionCheck` component in the frontend to monitor build version changes and refresh the application when a new version is detected. - Updated existing routers to include necessary imports for the new functionality. --- src/backend/main.py | 2 + src/backend/routers/app_router.py | 24 ++++++++++ src/backend/routers/workspace_router.py | 3 ++ src/frontend/index.tsx | 2 + src/frontend/src/BuildVersionCheck.tsx | 61 +++++++++++++++++++++++++ src/frontend/src/api/hooks.ts | 24 ++++++++++ src/frontend/src/utils/canvasUtils.ts | 58 +++++++++++++++++++++++ src/frontend/vite.config.mts | 33 ++++++++++++- 8 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/backend/routers/app_router.py create mode 100644 src/frontend/src/BuildVersionCheck.tsx diff --git a/src/backend/main.py b/src/backend/main.py index 3b51dec..c9700a6 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -17,6 +17,7 @@ from routers.workspace_router import workspace_router from routers.pad_router import pad_router from routers.template_pad_router import template_pad_router +from routers.app_router import app_router from database.service import TemplatePadService from database.database import async_session, run_migrations_with_lock @@ -140,6 +141,7 @@ async def read_root(request: Request, auth: Optional[UserSession] = Depends(opti app.include_router(workspace_router, prefix="/api/workspace") app.include_router(pad_router, prefix="/api/pad") app.include_router(template_pad_router, prefix="/api/templates") +app.include_router(app_router, prefix="/api/app") if __name__ == "__main__": import uvicorn diff --git a/src/backend/routers/app_router.py b/src/backend/routers/app_router.py new file mode 100644 index 0000000..746fcba --- /dev/null +++ b/src/backend/routers/app_router.py @@ -0,0 +1,24 @@ +import os +import json +import time + +from fastapi import APIRouter +from config import STATIC_DIR + +app_router = APIRouter() + +@app_router.get("/build-info") +async def get_build_info(): + """ + Return the current build information from the static assets + """ + try: + # Read the build-info.json file that will be generated during build + build_info_path = os.path.join(STATIC_DIR, "build-info.json") + with open(build_info_path, 'r') as f: + build_info = json.load(f) + return build_info + except Exception as e: + # Return a default response if file doesn't exist + print(f"Error reading build-info.json: {str(e)}") + return {"buildHash": "development", "timestamp": int(time.time())} diff --git a/src/backend/routers/workspace_router.py b/src/backend/routers/workspace_router.py index c59d5e1..95577e6 100644 --- a/src/backend/routers/workspace_router.py +++ b/src/backend/routers/workspace_router.py @@ -1,4 +1,6 @@ import os +import json +import time from pydantic import BaseModel from fastapi import APIRouter, Depends, HTTPException @@ -6,6 +8,7 @@ from dependencies import UserSession, require_auth, get_coder_api from coder import CoderAPI +from config import STATIC_DIR workspace_router = APIRouter() diff --git a/src/frontend/index.tsx b/src/frontend/index.tsx index 35c5414..2d4d0b3 100644 --- a/src/frontend/index.tsx +++ b/src/frontend/index.tsx @@ -15,6 +15,7 @@ import type * as TExcalidraw from "@atyrode/excalidraw"; import App from "./src/App"; import AuthGate from "./src/AuthGate"; +import { BuildVersionCheck } from "./src/BuildVersionCheck"; declare global { @@ -31,6 +32,7 @@ async function initApp() { + { }} diff --git a/src/frontend/src/BuildVersionCheck.tsx b/src/frontend/src/BuildVersionCheck.tsx new file mode 100644 index 0000000..98bce93 --- /dev/null +++ b/src/frontend/src/BuildVersionCheck.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useBuildInfo, useSaveCanvas } from './api/hooks'; +import { saveCurrentCanvas } from './utils/canvasUtils'; + +/** + * Component that checks for application version changes and refreshes the page when needed. + * This component doesn't render anything visible. + */ +export function BuildVersionCheck() { + // Store the initial build hash when the component first loads + const [initialBuildHash, setInitialBuildHash] = useState(null); + + // Query for the current build info from the server + const { data: buildInfo } = useBuildInfo(); + + // Get the saveCanvas mutation + const { mutate: saveCanvas } = useSaveCanvas({ + onSuccess: () => { + console.debug("[pad.ws] Canvas saved before refresh"); + // Refresh the page immediately after saving + window.location.reload(); + }, + onError: (error) => { + console.error("[pad.ws] Failed to save canvas before refresh:", error); + // Refresh anyway even if save fails + window.location.reload(); + } + }); + + // Function to handle version update + const handleVersionUpdate = useCallback(() => { + // Save the canvas and then refresh + saveCurrentCanvas( + saveCanvas, + undefined, // No success callback needed as it's handled in the useSaveCanvas hook + () => window.location.reload() // On error, just refresh + ); + }, [saveCanvas]); + + useEffect(() => { + // On first load, store the initial build hash + if (buildInfo?.buildHash && initialBuildHash === null) { + console.log('Initial build hash:', buildInfo.buildHash); + setInitialBuildHash(buildInfo.buildHash); + } + + // If we have both values and they don't match, a new version is available + if (initialBuildHash !== null && + buildInfo?.buildHash && + initialBuildHash !== buildInfo.buildHash) { + + console.log('New version detected. Current:', initialBuildHash, 'New:', buildInfo.buildHash); + + // Save the canvas and then refresh + handleVersionUpdate(); + } + }, [buildInfo, initialBuildHash, handleVersionUpdate]); + + // This component doesn't render anything + return null; +} diff --git a/src/frontend/src/api/hooks.ts b/src/frontend/src/api/hooks.ts index 6747eca..c4a7a5e 100644 --- a/src/frontend/src/api/hooks.ts +++ b/src/frontend/src/api/hooks.ts @@ -39,6 +39,11 @@ export interface CanvasBackupsResponse { backups: CanvasBackup[]; } +export interface BuildInfo { + buildHash: string; + timestamp: number; +} + // API functions export const api = { // Authentication @@ -134,6 +139,16 @@ export const api = { throw error; } }, + + // Build Info + getBuildInfo: async (): Promise => { + try { + const result = await fetchApi('/api/app/build-info'); + return result; + } catch (error) { + throw error; + } + }, }; // Query hooks @@ -192,6 +207,15 @@ export function useCanvasBackups(limit: number = 10, options?: UseQueryOptions) { + return useQuery({ + queryKey: ['buildInfo'], + queryFn: api.getBuildInfo, + refetchInterval: 60000, // Check every minute + ...options, + }); +} + // Mutation hooks export function useStartWorkspace(options?: UseMutationOptions) { return useMutation({ diff --git a/src/frontend/src/utils/canvasUtils.ts b/src/frontend/src/utils/canvasUtils.ts index 79d2c02..c6578c8 100644 --- a/src/frontend/src/utils/canvasUtils.ts +++ b/src/frontend/src/utils/canvasUtils.ts @@ -1,4 +1,6 @@ import { DEFAULT_SETTINGS } from '../types/settings'; +import type { ExcalidrawImperativeAPI } from "@atyrode/excalidraw/types"; +import { CanvasData } from '../api/hooks'; /** * @@ -42,3 +44,59 @@ export function normalizeCanvasData(data: any) { return { ...data, appState }; } + +/** + * Saves the current canvas state using the Excalidraw API + * @param saveCanvas The saveCanvas mutation function from useSaveCanvas hook + * @param onSuccess Optional callback to run after successful save + * @param onError Optional callback to run if save fails + */ +export function saveCurrentCanvas( + saveCanvas: (data: CanvasData) => void, + onSuccess?: () => void, + onError?: (error: any) => void +) { + try { + // Get the excalidrawAPI from the window object + const excalidrawAPI = (window as any).excalidrawAPI as ExcalidrawImperativeAPI | null; + + if (excalidrawAPI) { + // Get the current elements, state, and files + const elements = excalidrawAPI.getSceneElements(); + const appState = excalidrawAPI.getAppState(); + const files = excalidrawAPI.getFiles(); + + // Save the canvas data + saveCanvas({ + elements: [...elements] as any[], // Convert readonly array to mutable array + appState, + files + }); + + // Call onSuccess callback if provided + if (onSuccess) { + onSuccess(); + } + + return true; + } else { + console.warn("[pad.ws] ExcalidrawAPI not available"); + + // Call onError callback if provided + if (onError) { + onError(new Error("ExcalidrawAPI not available")); + } + + return false; + } + } catch (error) { + console.error("[pad.ws] Error saving canvas:", error); + + // Call onError callback if provided + if (onError) { + onError(error); + } + + return false; + } +} diff --git a/src/frontend/vite.config.mts b/src/frontend/vite.config.mts index e68ceb2..47e988d 100644 --- a/src/frontend/vite.config.mts +++ b/src/frontend/vite.config.mts @@ -1,4 +1,32 @@ -import { defineConfig, loadEnv } from "vite"; +import { defineConfig, loadEnv, Plugin } from "vite"; +import fs from "fs"; +import path from "path"; + +// Create a plugin to generate build-info.json during build +const generateBuildInfoPlugin = (): Plugin => ({ + name: 'generate-build-info', + closeBundle() { + // Generate a unique build hash (timestamp + random string) + const buildInfo = { + buildHash: Date.now().toString(36) + Math.random().toString(36).substring(2), + timestamp: Date.now() + }; + + // Ensure the dist directory exists + const distDir = path.resolve(__dirname, 'dist'); + if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); + } + + // Write to the output directory + fs.writeFileSync( + path.resolve(distDir, 'build-info.json'), + JSON.stringify(buildInfo, null, 2) + ); + + console.log('Generated build-info.json with hash:', buildInfo.buildHash); + } +}); // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { @@ -24,6 +52,9 @@ export default defineConfig(({ mode }) => { 'import.meta.env.CODER_URL': JSON.stringify(env.CODER_URL), }, publicDir: "public", + plugins: [ + generateBuildInfoPlugin(), + ], optimizeDeps: { esbuildOptions: { // Bumping to 2022 due to "Arbitrary module namespace identifier names" not being From eb065c567d6b858a2df7db736f5ec1642f2d004e Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 4 May 2025 19:20:31 +0000 Subject: [PATCH 2/2] refactor: remove runtime config generation and implement API for app configuration - Removed the runtime configuration generation from the startup script, streamlining the application startup process. - Added a new endpoint `/config` in the app router to serve application configuration, fetching values from environment variables. - Updated the frontend to retrieve configuration dynamically from the new API instead of relying on a static runtime config file. - Refactored the PostHog initialization to use the fetched configuration, improving analytics setup. --- scripts/startup.sh | 10 ------ src/backend/routers/app_router.py | 11 ++++++ src/frontend/index.html | 3 +- src/frontend/src/AuthGate.tsx | 51 ++++++++++++++++++--------- src/frontend/src/api/configService.ts | 39 ++++++++++++++++++++ src/frontend/src/global.d.ts | 5 --- src/frontend/src/utils/posthog.ts | 27 +++++++------- 7 files changed, 100 insertions(+), 46 deletions(-) create mode 100644 src/frontend/src/api/configService.ts diff --git a/scripts/startup.sh b/scripts/startup.sh index c715655..d9d3e93 100644 --- a/scripts/startup.sh +++ b/scripts/startup.sh @@ -1,15 +1,5 @@ #!/bin/bash set -e -# Create runtime config with environment variables -mkdir -p /app/frontend/dist/assets -cat > /app/frontend/dist/assets/runtime-config.js < - + Pad.ws - diff --git a/src/frontend/src/AuthGate.tsx b/src/frontend/src/AuthGate.tsx index f2a7fb7..6434020 100644 --- a/src/frontend/src/AuthGate.tsx +++ b/src/frontend/src/AuthGate.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; import { useAuthCheck } from "./api/hooks"; +import { getAppConfig } from "./api/configService"; /** * If unauthenticated, it shows the AuthModal as an overlay, but still renders the app behind it. @@ -19,26 +20,42 @@ export default function AuthGate({ children }: { children: React.ReactNode }) { useEffect(() => { // Only run the Coder OIDC priming once per session, after auth is confirmed if (isAuthenticated === true && !coderAuthDone) { - const iframe = document.createElement("iframe"); - iframe.style.display = "none"; - // Use runtime config if available, fall back to import.meta.env - const coderUrl = window.RUNTIME_CONFIG?.CODER_URL || import.meta.env.CODER_URL; - iframe.src = `${coderUrl}/api/v2/users/oidc/callback`; - console.debug(`[pad.ws] (Silently) Priming Coder OIDC session for ${coderUrl}`); + const setupIframe = async () => { + try { + // Get config from API + const config = await getAppConfig(); + + if (!config.coderUrl) { + console.warn('[pad.ws] Coder URL not found, skipping OIDC priming'); + setCoderAuthDone(true); + return; + } + + const iframe = document.createElement("iframe"); + iframe.style.display = "none"; + iframe.src = `${config.coderUrl}/api/v2/users/oidc/callback`; + console.debug(`[pad.ws] (Silently) Priming Coder OIDC session for ${config.coderUrl}`); - // Remove iframe as soon as it loads, or after 2s fallback - const cleanup = () => { - if (iframe.parentNode) iframe.parentNode.removeChild(iframe); - setCoderAuthDone(true); - }; - - iframe.onload = cleanup; - document.body.appendChild(iframe); - iframeRef.current = iframe; + // Remove iframe as soon as it loads, or after fallback timeout + const cleanup = () => { + if (iframe.parentNode) iframe.parentNode.removeChild(iframe); + setCoderAuthDone(true); + }; - // Fallback: remove iframe after 5s if onload doesn't fire - timeoutRef.current = window.setTimeout(cleanup, 5000); + iframe.onload = cleanup; + document.body.appendChild(iframe); + iframeRef.current = iframe; + // Fallback: remove iframe after 5s if onload doesn't fire + timeoutRef.current = window.setTimeout(cleanup, 5000); + } catch (error) { + console.error('[pad.ws] Error setting up Coder OIDC priming:', error); + setCoderAuthDone(true); + } + }; + + setupIframe(); + // Cleanup on unmount or re-run return () => { if (iframeRef.current && iframeRef.current.parentNode) { diff --git a/src/frontend/src/api/configService.ts b/src/frontend/src/api/configService.ts new file mode 100644 index 0000000..66050cf --- /dev/null +++ b/src/frontend/src/api/configService.ts @@ -0,0 +1,39 @@ +import { fetchApi } from './apiUtils'; + +/** + * Application configuration interface + */ +export interface AppConfig { + coderUrl: string; + posthogKey: string; + posthogHost: string; +} + +// Cache the config to avoid unnecessary API calls +let cachedConfig: AppConfig | null = null; + +/** + * Get the application configuration from the API + * @returns The application configuration + */ +export async function getAppConfig(): Promise { + // Return cached config if available + if (cachedConfig) { + return cachedConfig; + } + + try { + // Fetch config from API + const config = await fetchApi('/api/app/config'); + cachedConfig = config; + return config; + } catch (error) { + console.error('[pad.ws] Failed to load application configuration:', error); + // Return default values as fallback + return { + coderUrl: '', + posthogKey: '', + posthogHost: '' + }; + } +} diff --git a/src/frontend/src/global.d.ts b/src/frontend/src/global.d.ts index db580b0..70713f3 100644 --- a/src/frontend/src/global.d.ts +++ b/src/frontend/src/global.d.ts @@ -1,8 +1,3 @@ interface Window { - RUNTIME_CONFIG?: { - CODER_URL: string; - VITE_PUBLIC_POSTHOG_KEY: string; - VITE_PUBLIC_POSTHOG_HOST: string; - }; ExcalidrawLib: any; } diff --git a/src/frontend/src/utils/posthog.ts b/src/frontend/src/utils/posthog.ts index 3ac4cfd..212eb14 100644 --- a/src/frontend/src/utils/posthog.ts +++ b/src/frontend/src/utils/posthog.ts @@ -1,17 +1,20 @@ import posthog from 'posthog-js'; +import { getAppConfig } from '../api/configService'; -const posthogKey = window.RUNTIME_CONFIG?.VITE_PUBLIC_POSTHOG_KEY || import.meta.env.VITE_PUBLIC_POSTHOG_KEY; -const posthogHost = window.RUNTIME_CONFIG?.VITE_PUBLIC_POSTHOG_HOST || import.meta.env.VITE_PUBLIC_POSTHOG_HOST; +// Initialize PostHog with empty values first +posthog.init('', { api_host: '' }); -// Initialize PostHog -if (posthogKey) { - posthog.init(posthogKey, { - api_host: posthogHost, - }); - console.debug('[pad.ws] PostHog initialized successfully'); -} else { - console.warn('[pad.ws] PostHog API key not found. Analytics will not be tracked.'); -} +// Then update with real values when config is loaded +getAppConfig().then(config => { + if (config.posthogKey) { + posthog.init(config.posthogKey, { + api_host: config.posthogHost, + }); + console.debug('[pad.ws] PostHog initialized successfully'); + } else { + console.warn('[pad.ws] PostHog API key not found. Analytics will not be tracked.'); + } +}); // Helper function to track custom events export const capture = (eventName: string, properties?: Record) => { @@ -19,4 +22,4 @@ export const capture = (eventName: string, properties?: Record) => }; // Export PostHog instance for direct use -export default posthog; \ No newline at end of file +export default posthog;