From f17e081a3a0ff122997eba82ed9c19cc82f7bd8d Mon Sep 17 00:00:00 2001 From: pballai Date: Wed, 27 Aug 2025 21:31:23 -0400 Subject: [PATCH] Update Recipe Portal - Complete QuickStarts API Toolkit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Advanced API Explorer with smart parameter detection - Token management and secure key storage - Comprehensive recipe collection (members, teams, workbooks, connections, embedding) - Download and export functionality - Complete authentication system - Request analyzer and smart parameter forms This replaces the previous incomplete version with the full-featured toolkit. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- recipe-portal/.gitignore | 39 +- recipe-portal/README.md | 129 ++ recipe-portal/app/api/call/route.ts | 141 ++ recipe-portal/app/api/code/route.ts | 6 +- .../app/api/download-stream/route.ts | 695 +++++++++ recipe-portal/app/api/download/route.ts | 408 ++++++ recipe-portal/app/api/env/route.ts | 2 +- recipe-portal/app/api/execute/route.ts | 62 +- recipe-portal/app/api/keys/route.ts | 148 ++ recipe-portal/app/api/open-folder/route.ts | 43 + recipe-portal/app/api/readme/route.ts | 125 +- recipe-portal/app/api/resources/route.ts | 281 ++++ recipe-portal/app/api/token/clear/route.ts | 65 + recipe-portal/app/api/token/route.ts | 87 ++ recipe-portal/app/layout.tsx | 9 +- recipe-portal/app/page.tsx | 255 +++- recipe-portal/app/test/page.tsx | 8 + recipe-portal/bug.png | Bin 0 -> 121989 bytes recipe-portal/components/CodeViewer.tsx | 1285 ++++++++++++++--- recipe-portal/components/QuickApiExplorer.tsx | 287 ++++ recipe-portal/components/QuickApiModal.tsx | 259 ++++ recipe-portal/components/RecipeCard.tsx | 81 +- .../components/SmartParameterForm.tsx | 455 ++++++ recipe-portal/lib/keyStorage.ts | 271 ++++ recipe-portal/lib/recipeScanner.ts | 28 +- recipe-portal/lib/requestAnalyzer.ts | 224 +++ recipe-portal/lib/smartParameters.ts | 488 +++++++ recipe-portal/public/crane.png | Bin 0 -> 11810 bytes recipe-portal/recipes/.member-emails | 3 + .../connections/list_all_connections.js | 64 + .../connections/list_all_connections.md | 22 + .../recipes/connections/sync_schema.js | 176 +++ .../recipes/connections/sync_schema.md | 27 + .../embedding/generate_workbook_embed_path.js | 92 ++ .../embedding/generate_workbook_embed_path.md | 25 + recipe-portal/recipes/get-access-token.js | 60 + recipe-portal/recipes/launch.json | 13 + .../recipes/members/bulk-create-members.js | 183 +++ .../recipes/members/bulk-create-members.md | 73 + .../recipes/members/bulk-deactivate.js | 115 ++ .../recipes/members/bulk-deactivate.md | 25 + .../members/create-connection-permission.js | 52 + .../members/create-connection-permission.md | 24 + recipe-portal/recipes/members/create-new.js | 112 ++ recipe-portal/recipes/members/create-new.md | 28 + .../members/create-workspace-permission.js | 94 ++ .../members/create-workspace-permission.md | 28 + .../recipes/members/create-workspace.js | 100 ++ .../recipes/members/create-workspace.md | 24 + .../recipes/members/deactivate-existing.js | 37 + .../recipes/members/deactivate-existing.md | 24 + .../recipes/members/get-member-details.js | 58 + .../recipes/members/get-member-details.md | 25 + recipe-portal/recipes/members/list-all.js | 133 ++ recipe-portal/recipes/members/list-all.md | 67 + .../recipes/members/master-script.js | 145 ++ .../recipes/members/master-script.md | 24 + .../recipes/members/recent-workbooks.js | 78 + .../recipes/members/recent-workbooks.md | 24 + recipe-portal/recipes/members/update.js | 52 + recipe-portal/recipes/members/update.md | 24 + recipe-portal/recipes/members_output.json | 92 ++ recipe-portal/recipes/package-lock.json | 304 ++++ recipe-portal/recipes/package.json | 10 + .../recipes/teams/add-member-to-team.js | 44 + .../recipes/teams/add-member-to-team.md | 24 + .../recipes/teams/bulk-assign-team.js | 77 + .../recipes/teams/bulk-assign-team.md | 34 + .../recipes/teams/bulk-remove-team.js | 81 ++ .../recipes/teams/bulk-remove-team.md | 34 + .../PlugsSalesPerformanceDashboard.pdf.temp | Bin 0 -> 61867 bytes .../recipes/workbooks/all-input-tables.js | 84 ++ .../recipes/workbooks/all-input-tables.md | 26 + .../recipes/workbooks/copy-workbook-folder.js | 104 ++ .../recipes/workbooks/copy-workbook-folder.md | 26 + .../workbooks/export-workbook-element-csv.js | 291 ++++ .../workbooks/export-workbook-element-csv.md | 62 + .../recipes/workbooks/export-workbook-pdf.js | 180 +++ .../recipes/workbooks/export-workbook-pdf.md | 51 + .../workbooks/get-column-names-all-pages | 125 ++ .../workbooks/get-workbooks-name-url-TABLE.js | 69 + .../workbooks/get-workbooks-name-url-TABLE.md | 25 + .../workbooks/initiate-materialization.js | 137 ++ .../workbooks/initiate-materialization.md | 35 + recipe-portal/recipes/workbooks/list-all.js | 121 ++ recipe-portal/recipes/workbooks/list-all.md | 52 + .../workbooks/list-workbooks-by-owner.js | 71 + .../workbooks/list-workbooks-by-owner.md | 30 + recipe-portal/recipes/workbooks/pagination.js | 98 ++ recipe-portal/recipes/workbooks/pagination.md | 39 + .../recipes/workbooks/shared-with-memberId.js | 76 + .../recipes/workbooks/shared-with-memberId.md | 25 + recipe-portal/recipes/workbooks/test-export | 25 + .../recipes/workbooks/test-export.pdf | Bin 0 -> 61867 bytes recipe-portal/recipes/workbooks/test.temp | 135 ++ .../recipes/workbooks/update-owner.js | 44 + .../recipes/workbooks/update-owner.md | 25 + 97 files changed, 10186 insertions(+), 382 deletions(-) create mode 100644 recipe-portal/README.md create mode 100644 recipe-portal/app/api/call/route.ts create mode 100644 recipe-portal/app/api/download-stream/route.ts create mode 100644 recipe-portal/app/api/download/route.ts create mode 100644 recipe-portal/app/api/keys/route.ts create mode 100644 recipe-portal/app/api/open-folder/route.ts create mode 100644 recipe-portal/app/api/resources/route.ts create mode 100644 recipe-portal/app/api/token/clear/route.ts create mode 100644 recipe-portal/app/api/token/route.ts create mode 100644 recipe-portal/app/test/page.tsx create mode 100644 recipe-portal/bug.png create mode 100644 recipe-portal/components/QuickApiExplorer.tsx create mode 100644 recipe-portal/components/QuickApiModal.tsx create mode 100644 recipe-portal/components/SmartParameterForm.tsx create mode 100644 recipe-portal/lib/keyStorage.ts create mode 100644 recipe-portal/lib/requestAnalyzer.ts create mode 100644 recipe-portal/lib/smartParameters.ts create mode 100644 recipe-portal/public/crane.png create mode 100644 recipe-portal/recipes/.member-emails create mode 100644 recipe-portal/recipes/connections/list_all_connections.js create mode 100644 recipe-portal/recipes/connections/list_all_connections.md create mode 100644 recipe-portal/recipes/connections/sync_schema.js create mode 100644 recipe-portal/recipes/connections/sync_schema.md create mode 100644 recipe-portal/recipes/embedding/generate_workbook_embed_path.js create mode 100644 recipe-portal/recipes/embedding/generate_workbook_embed_path.md create mode 100644 recipe-portal/recipes/get-access-token.js create mode 100644 recipe-portal/recipes/launch.json create mode 100644 recipe-portal/recipes/members/bulk-create-members.js create mode 100644 recipe-portal/recipes/members/bulk-create-members.md create mode 100644 recipe-portal/recipes/members/bulk-deactivate.js create mode 100644 recipe-portal/recipes/members/bulk-deactivate.md create mode 100644 recipe-portal/recipes/members/create-connection-permission.js create mode 100644 recipe-portal/recipes/members/create-connection-permission.md create mode 100644 recipe-portal/recipes/members/create-new.js create mode 100644 recipe-portal/recipes/members/create-new.md create mode 100644 recipe-portal/recipes/members/create-workspace-permission.js create mode 100644 recipe-portal/recipes/members/create-workspace-permission.md create mode 100644 recipe-portal/recipes/members/create-workspace.js create mode 100644 recipe-portal/recipes/members/create-workspace.md create mode 100644 recipe-portal/recipes/members/deactivate-existing.js create mode 100644 recipe-portal/recipes/members/deactivate-existing.md create mode 100644 recipe-portal/recipes/members/get-member-details.js create mode 100644 recipe-portal/recipes/members/get-member-details.md create mode 100644 recipe-portal/recipes/members/list-all.js create mode 100644 recipe-portal/recipes/members/list-all.md create mode 100644 recipe-portal/recipes/members/master-script.js create mode 100644 recipe-portal/recipes/members/master-script.md create mode 100644 recipe-portal/recipes/members/recent-workbooks.js create mode 100644 recipe-portal/recipes/members/recent-workbooks.md create mode 100644 recipe-portal/recipes/members/update.js create mode 100644 recipe-portal/recipes/members/update.md create mode 100644 recipe-portal/recipes/members_output.json create mode 100644 recipe-portal/recipes/package-lock.json create mode 100644 recipe-portal/recipes/package.json create mode 100644 recipe-portal/recipes/teams/add-member-to-team.js create mode 100644 recipe-portal/recipes/teams/add-member-to-team.md create mode 100644 recipe-portal/recipes/teams/bulk-assign-team.js create mode 100644 recipe-portal/recipes/teams/bulk-assign-team.md create mode 100644 recipe-portal/recipes/teams/bulk-remove-team.js create mode 100644 recipe-portal/recipes/teams/bulk-remove-team.md create mode 100644 recipe-portal/recipes/workbooks/PlugsSalesPerformanceDashboard.pdf.temp create mode 100644 recipe-portal/recipes/workbooks/all-input-tables.js create mode 100644 recipe-portal/recipes/workbooks/all-input-tables.md create mode 100644 recipe-portal/recipes/workbooks/copy-workbook-folder.js create mode 100644 recipe-portal/recipes/workbooks/copy-workbook-folder.md create mode 100644 recipe-portal/recipes/workbooks/export-workbook-element-csv.js create mode 100644 recipe-portal/recipes/workbooks/export-workbook-element-csv.md create mode 100644 recipe-portal/recipes/workbooks/export-workbook-pdf.js create mode 100644 recipe-portal/recipes/workbooks/export-workbook-pdf.md create mode 100644 recipe-portal/recipes/workbooks/get-column-names-all-pages create mode 100644 recipe-portal/recipes/workbooks/get-workbooks-name-url-TABLE.js create mode 100644 recipe-portal/recipes/workbooks/get-workbooks-name-url-TABLE.md create mode 100644 recipe-portal/recipes/workbooks/initiate-materialization.js create mode 100644 recipe-portal/recipes/workbooks/initiate-materialization.md create mode 100644 recipe-portal/recipes/workbooks/list-all.js create mode 100644 recipe-portal/recipes/workbooks/list-all.md create mode 100644 recipe-portal/recipes/workbooks/list-workbooks-by-owner.js create mode 100644 recipe-portal/recipes/workbooks/list-workbooks-by-owner.md create mode 100644 recipe-portal/recipes/workbooks/pagination.js create mode 100644 recipe-portal/recipes/workbooks/pagination.md create mode 100644 recipe-portal/recipes/workbooks/shared-with-memberId.js create mode 100644 recipe-portal/recipes/workbooks/shared-with-memberId.md create mode 100644 recipe-portal/recipes/workbooks/test-export create mode 100644 recipe-portal/recipes/workbooks/test-export.pdf create mode 100644 recipe-portal/recipes/workbooks/test.temp create mode 100644 recipe-portal/recipes/workbooks/update-owner.js create mode 100644 recipe-portal/recipes/workbooks/update-owner.md diff --git a/recipe-portal/.gitignore b/recipe-portal/.gitignore index 8d8b4022..e659078e 100644 --- a/recipe-portal/.gitignore +++ b/recipe-portal/.gitignore @@ -1,35 +1,42 @@ -# Dependencies -node_modules/ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules /.pnp .pnp.js -# Testing +# testing /coverage -# Next.js +# next.js /.next/ /out/ -# Production +# production /build -# Misc +# misc .DS_Store -*.tsbuildinfo -next-env.d.ts +*.pem -# Debug +# debug npm-debug.log* yarn-debug.log* yarn-error.log* -# Local env files +# local env files .env*.local -# IDE -.vscode/ -.idea/ +# vercel +.vercel -# OS -.DS_Store -Thumbs.db \ No newline at end of file +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sigma API encrypted credentials (security) +.sigma-portal/ +sigma-portal-keys.json + +# Environment files +.env \ No newline at end of file diff --git a/recipe-portal/README.md b/recipe-portal/README.md new file mode 100644 index 00000000..ce05f86e --- /dev/null +++ b/recipe-portal/README.md @@ -0,0 +1,129 @@ +# QuickStarts API Toolkit +Experiment with Sigma API calls and learn common request flows + +## Features + +### Recipes: +- **Smart Parameter Detection**: Automatically detects and provides dropdown selection for Sigma resources (teams, members, workbooks, etc.) +- **Interactive Execution**: Run recipes directly in the browser with real-time results +- **Parameter Summary**: View which parameters were used in each request +- **Code Viewing**: Browse the actual JavaScript code for each recipe + +### Quick API Explorer: +- **Common Endpoints**: Curated list of the most useful Sigma API endpoints +- **Zero Setup**: List endpoints require no parameters - perfect for quick exploration +- **One Parameter**: Detail endpoints need just one ID to get specific resource information +- **Alphabetical Organization**: Easy to find the endpoint you need + +## Authentication & Config Management + +### Smart Config System: +- **Complete Configuration Storage**: Server endpoints + API credentials stored together as named "configs" +- **Multi-Environment Support**: Easily switch between Production, Staging, Development environments +- **One-Click Environment Switching**: Load complete configurations instantly +- **Encrypted Local Storage**: AES-256 encryption for credential security + +### Config Management Features: +- **Quick Start**: Load saved configs with one click - no manual entry needed +- **Create New Configs**: Mix and match server endpoints with credentials +- **Update Existing Configs**: Modify and save changes to existing configurations +- **Delete Configs**: Remove configs you no longer need +- **Auto-Save**: Configs saved automatically during authentication when enabled +- **Manual Save**: Explicit save button for immediate config storage + +### Token Management: +- **File-Based Storage**: Authentication tokens cached in system temp directory +- **Persistent Sessions**: Tokens survive browser/server restarts for the full hour +- **Automatic Expiration**: Tokens expire after 1 hour (Sigma's standard lifetime) +- **Auto-Cleanup**: Expired tokens automatically detected and removed +- **Manual Session End**: Clear authentication anytime with 🚪 End Session button + +### Storage Locations + +**Config Storage (encrypted)**: +- **macOS**: `~/Library/Application Support/.sigma-portal/encrypted-keys.json` +- **Windows**: `%APPDATA%\.sigma-portal\encrypted-keys.json` +- **Linux**: `~/.config/.sigma-portal/encrypted-keys.json` + +**Token Cache (temporary)**: +- **macOS**: `/var/folders/.../sigma-portal-token.json` +- **Windows**: `%TEMP%\sigma-portal-token.json` +- **Linux**: `/tmp/sigma-portal-token.json` + +### Developer Experience Benefits +- **Environment Switching**: Instant switch between Production ↔ Staging ↔ Development +- **Zero Re-entry**: Load complete configs without typing credentials repeatedly +- **Secure Storage**: Military-grade AES-256 encryption for stored credentials +- **Clean Separation**: Configs stored outside project directory (never committed to git) +- **Visual Feedback**: Clear indicators show saved/unsaved state and notifications +- **Flexible Workflow**: Session-only credentials OR persistent named configs + +### Config Workflow +1. **First Time**: Enter server endpoint + credentials → Save as named config (e.g., "Production") +2. **Daily Use**: Quick Start → Select "Production" → Instantly loaded and ready +3. **Environment Switch**: Quick Start → Select "Staging" → Switched in one click +4. **New Environment**: "✨ New Config" → Enter details → Save with new name + +## Getting Started +Sigma_QuickStart_Public_Repo + + +1. **Setup**: `npm install && npm run dev` +2. **First-Time Config**: Open any recipe → **Config** tab → Enter server endpoint + credentials → Save as named config +3. **Daily Use**: **Quick Start** section → Select your saved config → Ready to go! +4. **Explore**: Use the ⚔ Quick API tab to explore common endpoints with smart parameters +5. **Run Recipes**: Browse recipes by category and execute them with real-time results + +### Config Tab Features +- **Quick Start**: Load saved configs instantly (appears when configs exist) +- **Server Endpoint**: Choose your Sigma organization's server location +- **API Credentials**: Enter Client ID and Client Secret +- **Config Storage**: Save complete configurations with names like "Production", "Staging" +- **Save Config**: Manual save button for immediate storage +- **New Config**: Clear form to create fresh configurations +- **Delete**: Remove configs you no longer need (šŸ—‘ļø button when config selected) + +## Requirements +- Node.js 18+ +- Sigma API credentials (Client ID and Secret) +- Valid Sigma organization access + +## Development +```bash +npm install +npm run dev +``` + +Navigate to `http://localhost:3001` to start exploring the Sigma API. + +## Project Structure +``` +recipe-portal/ +ā”œā”€ā”€ app/ # Next.js app directory +│ ā”œā”€ā”€ api/ # API routes +│ │ ā”œā”€ā”€ execute/ # Recipe execution +│ │ ā”œā”€ā”€ resources/ # Resource fetching for dropdowns +│ │ ā”œā”€ā”€ keys/ # Config management (CRUD operations) +│ │ ā”œā”€ā”€ token/ # Token management & caching +│ │ └── call/ # Quick API endpoint calls +ā”œā”€ā”€ components/ # React components +│ ā”œā”€ā”€ QuickApiExplorer.tsx # Quick API exploration interface +│ ā”œā”€ā”€ QuickApiModal.tsx # API endpoint execution modal +│ ā”œā”€ā”€ SmartParameterForm.tsx # Smart parameter detection & forms +│ ā”œā”€ā”€ CodeViewer.tsx # Recipe viewer with Config tab +│ ā”œā”€ā”€ AuthRecipeCard.tsx # Authentication recipe card +│ └── RecipeCard.tsx # Standard recipe cards +ā”œā”€ā”€ lib/ # Utilities +│ ā”œā”€ā”€ smartParameters.ts # Parameter detection logic +│ ā”œā”€ā”€ keyStorage.ts # Encrypted config storage +│ └── recipeScanner.ts # Recipe discovery & analysis +└── recipes/ # Self-contained recipe files (copied from sigma-api-recipes) + ā”œā”€ā”€ connections/ # Connection-related recipes + ā”œā”€ā”€ members/ # Member management recipes + ā”œā”€ā”€ teams/ # Team management recipes + ā”œā”€ā”€ workbooks/ # Workbook operations + ā”œā”€ā”€ embedding/ # Embedding examples + └── get-access-token.js # Authentication helper +``` + +For setup instructions and API credential creation, visit the QuickStart: [Sigma REST API Recipes](https://quickstarts.sigmacomputing.com/guide/developers_api_code_samples/index.html?index=..%2F..index#0) \ No newline at end of file diff --git a/recipe-portal/app/api/call/route.ts b/recipe-portal/app/api/call/route.ts new file mode 100644 index 00000000..ca483b8e --- /dev/null +++ b/recipe-portal/app/api/call/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from 'next/server'; +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const TOKEN_CACHE_FILE = path.join(os.tmpdir(), 'sigma-portal-token.json'); + +function getCachedToken(): string | null { + try { + if (fs.existsSync(TOKEN_CACHE_FILE)) { + const tokenData = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8')); + const now = Date.now(); + + // Check if token is still valid (not expired) + if (tokenData.expiresAt && now < tokenData.expiresAt) { + return tokenData.token; + } else { + // Token expired, remove file + fs.unlinkSync(TOKEN_CACHE_FILE); + } + } + } catch (error) { + // Ignore errors, just return null + } + return null; +} + +export async function POST(request: Request) { + try { + const { endpoint, method, parameters = {}, requestBody } = await request.json(); + + if (!endpoint) { + return NextResponse.json( + { error: 'Endpoint is required' }, + { status: 400 } + ); + } + + // Get cached token + const token = getCachedToken(); + if (!token) { + return NextResponse.json( + { + error: 'Authentication required', + message: 'No valid authentication token found. Please authenticate first.' + }, + { status: 401 } + ); + } + + // Build the full URL + const baseURL = process.env.SIGMA_BASE_URL || 'https://aws-api.sigmacomputing.com/v2'; + let url = `${baseURL}${endpoint}`; + + // Add query parameters + if (parameters.query && Object.keys(parameters.query).length > 0) { + const queryParams = new URLSearchParams(); + Object.entries(parameters.query).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + queryParams.append(key, String(value)); + } + }); + if (queryParams.toString()) { + url += `?${queryParams.toString()}`; + } + } + + // Prepare headers + const headers: Record = { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + + // Add header parameters + if (parameters.header) { + Object.entries(parameters.header).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + headers[key] = String(value); + } + }); + } + + // Make the API call + const response = await axios({ + method: method.toLowerCase(), + url, + headers, + data: requestBody, + timeout: 30000 // 30 second timeout + }); + + // Return successful response + return NextResponse.json({ + output: JSON.stringify(response.data, null, 2), + error: '', + success: true, + timestamp: new Date().toISOString(), + httpStatus: response.status, + httpStatusText: response.statusText, + requestUrl: url, + requestMethod: method + }); + + } catch (error: any) { + console.error('API call error:', error); + + let errorMessage = 'Unknown error occurred'; + let httpStatus = 500; + let httpStatusText = 'Internal Server Error'; + + if (axios.isAxiosError(error)) { + if (error.response) { + // Server responded with error status + httpStatus = error.response.status; + httpStatusText = error.response.statusText; + errorMessage = error.response.data?.message || error.response.data?.error || `HTTP ${httpStatus}: ${httpStatusText}`; + } else if (error.request) { + // Request made but no response + errorMessage = 'No response received from server'; + httpStatus = 0; + httpStatusText = 'Network Error'; + } else { + // Error setting up request + errorMessage = error.message; + } + } else { + errorMessage = error.message || 'Unknown error'; + } + + return NextResponse.json({ + output: '', + error: errorMessage, + success: false, + timestamp: new Date().toISOString(), + httpStatus, + httpStatusText + }); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/code/route.ts b/recipe-portal/app/api/code/route.ts index 3679adf8..68d68863 100644 --- a/recipe-portal/app/api/code/route.ts +++ b/recipe-portal/app/api/code/route.ts @@ -14,14 +14,14 @@ export async function GET(request: Request) { ); } - // Security check: ensure the file is within the sigma-api-recipes directory - const recipesPath = path.join(process.cwd(), '..', 'sigma-api-recipes'); + // Security check: ensure the file is within the recipes directory + const recipesPath = path.join(process.cwd(), 'recipes'); const resolvedPath = path.resolve(filePath); const resolvedRecipesPath = path.resolve(recipesPath); if (!resolvedPath.startsWith(resolvedRecipesPath)) { return NextResponse.json( - { error: 'Access denied: File must be within sigma-api-recipes directory' }, + { error: 'Access denied: File must be within recipes directory' }, { status: 403 } ); } diff --git a/recipe-portal/app/api/download-stream/route.ts b/recipe-portal/app/api/download-stream/route.ts new file mode 100644 index 00000000..a8716590 --- /dev/null +++ b/recipe-portal/app/api/download-stream/route.ts @@ -0,0 +1,695 @@ +import { NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export async function POST(request: Request) { + try { + // Log request details for debugging + console.log('Download stream request received from:', request.headers.get('referer')); + console.log('User agent:', request.headers.get('user-agent')); + + // Check if request has a body + const body = await request.text(); + if (!body || body.trim() === '') { + console.warn('Empty request body to download-stream endpoint'); + return NextResponse.json( + { error: 'Request body is empty. This endpoint is only for file download scripts.' }, + { status: 400 } + ); + } + + let parsedBody; + try { + parsedBody = JSON.parse(body); + } catch (parseError) { + return NextResponse.json( + { error: 'Invalid JSON in request body. This endpoint is only for file download scripts.' }, + { status: 400 } + ); + } + + const { filePath, envVariables, filename, contentType } = parsedBody; + + if (!filePath) { + return NextResponse.json( + { error: 'File path is required' }, + { status: 400 } + ); + } + + // Security check: ensure the file is within the recipes directory + const recipesPath = path.join(process.cwd(), 'recipes'); + const resolvedPath = path.resolve(filePath); + const resolvedRecipesPath = path.resolve(recipesPath); + + if (!resolvedPath.startsWith(resolvedRecipesPath)) { + return NextResponse.json( + { error: 'Access denied: File must be within recipes directory' }, + { status: 403 } + ); + } + + // Check if file exists + if (!fs.existsSync(resolvedPath)) { + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } + ); + } + + // Create a readable stream for server-sent events + const stream = new ReadableStream({ + start(controller) { + executeDownloadWithProgress(resolvedPath, envVariables, controller); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + + } catch (error) { + console.error('Error executing download stream:', error); + return NextResponse.json( + { error: 'Failed to start download stream' }, + { status: 500 } + ); + } +} + +async function executeDownloadWithProgress( + scriptPath: string, + envVariables: Record, + controller: ReadableStreamDefaultController +) { + const scriptDir = path.dirname(scriptPath); + const recipesRoot = path.join(scriptDir, '..'); + + // Create temporary .env file + const tempEnvPath = path.join(os.tmpdir(), `.env-${Date.now()}`); + let envContent = ''; + + if (envVariables && typeof envVariables === 'object') { + for (const [key, value] of Object.entries(envVariables)) { + if (typeof value === 'string') { + envContent += `${key}=${value}\n`; + } + } + } + + // Add common variables if not provided + if (envVariables && !envVariables.authURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `authURL=https://aws-api.sigmacomputing.com/v2/auth/token\n`; + } + if (envVariables && !envVariables.baseURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `baseURL=https://aws-api.sigmacomputing.com/v2\n`; + } + + envContent += `ENV_FILE_PATH=${tempEnvPath}\n`; + + + fs.writeFileSync(tempEnvPath, envContent); + + const sendProgress = (type: string, message: string, data?: any) => { + // Handle large content safely for JSON stringification + let safeData = data; + if (data && data.content && typeof data.content === 'string' && data.content.length > 10000) { + // For large content, create a truncated version for the JSON but keep the full content accessible + safeData = { + ...data, + content: '[Large content: ' + data.content.length + ' characters]', + _fullContent: data.content, // Store full content separately + _isLargeContent: true + }; + } + + try { + const event = `data: ${JSON.stringify({ type, message, data: safeData, timestamp: new Date().toISOString() })}\n\n`; + controller.enqueue(new TextEncoder().encode(event)); + } catch (error) { + // Fallback for JSON stringification errors + const fallbackEvent = `data: ${JSON.stringify({ type, message: message + ' (JSON error)', timestamp: new Date().toISOString() })}\n\n`; + controller.enqueue(new TextEncoder().encode(fallbackEvent)); + } + }; + + try { + sendProgress('info', 'Using cached authentication token'); + + // Create wrapper script for streaming progress + const scriptName = path.basename(scriptPath); + const wrapperScript = ` +process.chdir('${recipesRoot}'); + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set up environment variables +const envContent = fs.readFileSync('${tempEnvPath}', 'utf-8'); +const envLines = envContent.split('\\n'); + +envLines.forEach(line => { + const match = line.match(/^([^=]+)=(.*)$/); + if (match) { + process.env[match[1]] = match[2]; + } +}); + +// Token caching +const TOKEN_CACHE_FILE = path.join(os.tmpdir(), 'sigma-portal-token.json'); + +function getCachedToken() { + try { + if (fs.existsSync(TOKEN_CACHE_FILE)) { + const tokenData = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8')); + const now = Date.now(); + if (tokenData.expiresAt && now < tokenData.expiresAt) { + return tokenData.token; + } else { + fs.unlinkSync(TOKEN_CACHE_FILE); + } + } + } catch (error) {} + return null; +} + +// Global variables for capture +global.DOWNLOAD_CONTENT = null; +global.DOWNLOAD_FILENAME = null; +global.STREAM_FINISHED = false; +global.CAPTURE_IN_PROGRESS = false; + +// Override console.log to capture progress +const originalConsoleLog = console.log; +console.log = function(...args) { + const message = args.map(arg => String(arg)).join(' '); + + // For debugging - show ALL messages for now + process.stdout.write('PROGRESS:debug:' + message + '\\n'); + + // Also call original for any other logging + originalConsoleLog.apply(console, args); +}; + +// File capture system +const originalWriteFileSync = fs.writeFileSync; +const originalCreateWriteStream = fs.createWriteStream; + +fs.writeFileSync = function(filePath, data, options) { + if (filePath.endsWith('.json')) { + global.DOWNLOAD_CONTENT = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + global.DOWNLOAD_FILENAME = path.basename(filePath); + + // Write download data to temp file instead of stdout to avoid truncation + const downloadData = { + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME + }; + + try { + const resultFile = require('path').join(require('os').tmpdir(), \`download-result-\${Date.now()}.json\`); + require('fs').writeFileSync(resultFile, JSON.stringify(downloadData)); + process.stdout.write('DOWNLOAD_FILE:' + resultFile + '\\n'); + } catch (err) { + process.stdout.write(\`PROGRESS:error:Failed to write download result: \${err.message}\\n\`); + } + + process.exit(0); // Exit immediately after successful capture + return; + } + return originalWriteFileSync.call(this, filePath, data, options); +}; + +fs.createWriteStream = function(filePath, options) { + global.DOWNLOAD_FILENAME = path.basename(filePath); + global.DOWNLOAD_FILEPATH = filePath; + const tempFilePath = filePath + '.temp'; + + const realStream = originalCreateWriteStream.call(this, tempFilePath, options); + let totalBytesWritten = 0; + let writeCount = 0; + let lastWriteTime = Date.now(); + let inactivityTimer = null; + + const finishDownload = () => { + if (global.CAPTURE_IN_PROGRESS) return; // Prevent multiple captures + global.CAPTURE_IN_PROGRESS = true; + + process.stdout.write('PROGRESS:info:Finishing download, reading file...\\n'); + try { + realStream.end(); + setTimeout(() => { + process.stdout.write(\`PROGRESS:debug:Looking for temp file at: \${tempFilePath}\\n\`); + if (fs.existsSync(tempFilePath)) { + const fileData = fs.readFileSync(tempFilePath); + process.stdout.write(\`PROGRESS:debug:Successfully read \${fileData.length} bytes from temp file\\n\`); + global.DOWNLOAD_CONTENT = fileData.toString('base64'); + global.STREAM_FINISHED = true; + + // Write download data to temp file instead of stdout to avoid truncation + const downloadData = { + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME || 'export.pdf' + }; + + try { + const os = require('os'); + const path = require('path'); + const fs = require('fs'); + const tempDir = os.tmpdir(); + const resultFile = path.join(tempDir, \`download-result-\${Date.now()}-\${Math.random().toString(36).substring(7)}.json\`); + + fs.writeFileSync(resultFile, JSON.stringify(downloadData)); + process.stdout.write('DOWNLOAD_FILE:' + resultFile + '\\n'); + } catch (err) { + process.stdout.write(\`PROGRESS:error:Failed to write download result: \${err.message}\\n\`); + } + + try { fs.unlinkSync(tempFilePath); } catch (e) {} + process.exit(0); // Exit immediately after successful capture + } + }, 500); + } catch (err) { + process.stdout.write(\`PROGRESS:error:Error finishing download: \${err.message}\\n\`); + } + }; + + const mockStream = { + write: function(chunk) { + writeCount++; + totalBytesWritten += chunk.length; + lastWriteTime = Date.now(); + + // Clear any existing inactivity timer + if (inactivityTimer) { + clearTimeout(inactivityTimer); + } + + // Set a new inactivity timer - if no writes for 3 seconds, consider download complete + inactivityTimer = setTimeout(() => { + process.stdout.write('PROGRESS:info:Download appears complete (3s inactivity)\\n'); + finishDownload(); + }, 3000); + + // Show progress for first write and every 1000 writes to avoid spam + if (writeCount === 1 || writeCount % 1000 === 0) { + process.stdout.write(\`PROGRESS:info:Downloaded \${Math.round(totalBytesWritten/1024)}KB...\\n\`); + } + + return realStream.write(chunk); + }, + end: function(chunk) { + if (chunk) { + totalBytesWritten += chunk.length; + } + return realStream.end(chunk); + }, + on: function(event, callback) { + if (event === 'finish') { + realStream.on('finish', () => { + if (global.CAPTURE_IN_PROGRESS) return; // Prevent multiple captures + global.CAPTURE_IN_PROGRESS = true; + + process.stdout.write('PROGRESS:info:Stream finished, capturing file...\\n'); + try { + process.stdout.write(\`PROGRESS:debug:Stream finish - looking for temp file at: \${tempFilePath}\\n\`); + if (fs.existsSync(tempFilePath)) { + const fileData = fs.readFileSync(tempFilePath); + process.stdout.write(\`PROGRESS:debug:Stream finish - successfully read \${fileData.length} bytes\\n\`); + global.DOWNLOAD_CONTENT = fileData.toString('base64'); + + // Write download data to temp file instead of stdout to avoid truncation + const downloadData = { + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME || 'export.pdf' + }; + + try { + const os = require('os'); + const path = require('path'); + const fs = require('fs'); + const tempDir = os.tmpdir(); + const resultFile = path.join(tempDir, \`download-result-\${Date.now()}-\${Math.random().toString(36).substring(7)}.json\`); + + // Validate content first + if (!downloadData.content) { + throw new Error('Download content is null or undefined'); + } + + // Log details in one write to reduce race conditions + const debugInfo = [ + 'PROGRESS:debug:About to write result file: ' + resultFile, + 'PROGRESS:debug:Content length: ' + downloadData.content.length + ' chars', + 'PROGRESS:debug:Filename: ' + downloadData.filename + ].join('\\n') + '\\n'; + process.stdout.write(debugInfo); + + // Create JSON and write file + const jsonData = JSON.stringify(downloadData); + process.stdout.write('PROGRESS:debug:JSON data size: ' + jsonData.length + ' chars\\n'); + + // Write the file synchronously + fs.writeFileSync(resultFile, jsonData, 'utf8'); + + // Verify the file was written correctly + if (!fs.existsSync(resultFile)) { + throw new Error('Result file was not created'); + } + + const fileSize = fs.statSync(resultFile).size; + if (fileSize === 0) { + throw new Error('Result file is empty'); + } + + // Success - output file path and success message + const successInfo = [ + 'PROGRESS:debug:Result file written successfully (size: ' + fileSize + ' bytes)', + 'DOWNLOAD_FILE:' + resultFile + ].join('\\n') + '\\n'; + process.stdout.write(successInfo); + + } catch (err) { + const errorInfo = [ + 'PROGRESS:error:Failed to write download result: ' + err.message, + 'PROGRESS:error:Stack: ' + err.stack, + 'PROGRESS:error:Content available: ' + !!downloadData.content, + 'PROGRESS:error:Content length: ' + (downloadData.content ? downloadData.content.length : 'N/A') + ].join('\\n') + '\\n'; + process.stdout.write(errorInfo); + } + + try { fs.unlinkSync(tempFilePath); } catch (e) {} + + // Ensure stdout is flushed before exit + process.stdout.write('', () => { + process.exit(0); + }); + } + callback(); + } catch (err) { + process.stdout.write(\`PROGRESS:error:Error reading temp file: \${err.message}\\n\`); + callback(); + } + }); + return this; + } + if (event === 'error') { + realStream.on('error', callback); + return this; + } + return realStream.on(event, callback); + }, + once: function(event, callback) { + return realStream.once(event, callback); + }, + pipe: function(source) { + return source.pipe(realStream); + }, + close: function() { + return realStream.close(); + }, + destroy: function() { + return realStream.destroy(); + }, + writable: true, + readable: false + }; + + // Ensure the mock stream has all necessary EventEmitter methods + Object.setPrototypeOf(mockStream, realStream); + + return mockStream; +}; + +// Get cached token and execute +const cachedToken = getCachedToken(); +if (cachedToken) { + + let scriptContent = fs.readFileSync('${scriptPath}', 'utf-8'); + + const modifiedScript = scriptContent.replace( + /const getBearerToken = require\\(['"][^'"]*get-access-token['"]\\);/g, + 'const getBearerToken = async () => { return "' + cachedToken + '"; };' + ).replace( + /if \\(require\\.main === module\\) \\{([\\s\\S]*?)\\}/g, + '{ $1 }' + ).replace( + // Change the 10 second delay to 30 seconds for large datasets + /setTimeout\\(resolve, 10000\\)/g, + 'setTimeout(resolve, 30000)' + ).replace( + // Also update any 10000 millisecond delays + /await new Promise\\(resolve => setTimeout\\(resolve, 10000\\)\\)/g, + 'await new Promise(resolve => setTimeout(resolve, 30000))' + ); + + const tempScriptPath = '${scriptPath}' + '.stream.js'; + fs.writeFileSync(tempScriptPath, modifiedScript); + + try { + + delete require.cache[require.resolve(tempScriptPath)]; + require(tempScriptPath); + + // Check for completion + let checkCount = 0; + const maxChecks = 30; // 4 minutes max + + const checkForCompletion = () => { + checkCount++; + + if (global.DOWNLOAD_CONTENT) { + process.stdout.write('DOWNLOAD_RESULT:' + JSON.stringify({ + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME || 'export.pdf' + }) + '\\n'); + process.exit(0); + } else if (checkCount >= maxChecks) { + process.stdout.write('PROGRESS:timeout:Download timeout - export may have failed\\n'); + process.exit(1); + } else { + setTimeout(checkForCompletion, 8000); // Check every 8 seconds + } + }; + + setTimeout(checkForCompletion, 10000); // Wait 10 seconds for stream to finish before first check + + } finally { + try { + fs.unlinkSync(tempScriptPath); + } catch (err) {} + } +} else { + process.stdout.write('PROGRESS:error:No cached authentication token found\\n'); + process.exit(1); +} +`; + + const tempScriptPath = path.join(os.tmpdir(), `temp-stream-wrapper-${Date.now()}.js`); + fs.writeFileSync(tempScriptPath, wrapperScript); + + const child = spawn('node', [tempScriptPath], { + cwd: recipesRoot, + timeout: 600000, // 10 minute timeout for large datasets + }); + + let fileContent: string | null = null; + let filename: string | null = null; + + let downloadResultCapture = false; + let capturedFilename = ''; + let capturedContent = ''; + let downloadCompleted = false; // Flag to prevent duplicate success messages + + child.stdout?.on('data', (data) => { + const output = data.toString(); + const lines = output.split('\n'); + + for (const line of lines) { + if (line === 'DOWNLOAD_RESULT_START') { + downloadResultCapture = true; + sendProgress('info', 'Capturing download result...'); + } else if (line === 'DOWNLOAD_RESULT_END') { + downloadResultCapture = false; + // Process the captured data immediately + if (capturedFilename && capturedContent) { + try { + // Write content to JSON file and use the existing DOWNLOAD_FILE protocol + const tempResultPath = path.join(os.tmpdir(), `download-result-${Date.now()}.json`); + const downloadData = { + content: capturedContent, + filename: capturedFilename + }; + fs.writeFileSync(tempResultPath, JSON.stringify(downloadData)); + + // Don't use sendProgress for DOWNLOAD_FILE - it needs to be processed differently + // Store the file path for later processing + fileContent = capturedContent; + filename = capturedFilename; + sendProgress('debug', `Stored fileContent length: ${fileContent?.length || 0}, filename: ${filename || 'none'}`); + + // Also store as global variables as backup + (global as any).FINAL_DOWNLOAD_CONTENT = capturedContent; + (global as any).FINAL_DOWNLOAD_FILENAME = capturedFilename; + + sendProgress('success', 'Download completed!', { + filename: capturedFilename, + size: Math.round(capturedContent.length * 0.75) // Rough base64 to bytes + }); + + downloadCompleted = true; // Mark as completed to prevent duplicate messages + + } catch (err) { + sendProgress('error', 'Failed to process download result: ' + (err instanceof Error ? err.message : String(err))); + } + } + } else if (downloadResultCapture) { + if (line.startsWith('FILENAME:')) { + capturedFilename = line.substring(9); + sendProgress('debug', `Captured filename: ${capturedFilename}`); + } else if (line.startsWith('CONTENT:')) { + capturedContent = line.substring(8); + sendProgress('debug', `Captured content length: ${capturedContent.length} chars`); + } else if (line.trim() && capturedContent) { + // Append additional lines that are part of the base64 content + capturedContent += line; + sendProgress('debug', `Appended content, total length: ${capturedContent.length} chars`); + } + } else if (line.startsWith('PROGRESS:')) { + const [, type, message] = line.split(':', 3); + sendProgress(type, message); + } else if (line.startsWith('DOWNLOAD_FILE:')) { + try { + const filePath = line.substring(14); + sendProgress('debug', `Reading download file: ${filePath}`); + + if (!fs.existsSync(filePath)) { + throw new Error(`Download file does not exist: ${filePath}`); + } + + const fileStats = fs.statSync(filePath); + sendProgress('debug', `File size: ${fileStats.size} bytes`); + + const downloadData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + fileContent = downloadData.content; + filename = downloadData.filename; + + sendProgress('debug', `File content length: ${fileContent ? fileContent.length : 'null'}`); + sendProgress('debug', `Filename: ${filename}`); + + sendProgress('success', 'Download completed!', { + filename, + size: Math.round((fileContent?.length || 0) * 0.75) // Rough base64 to bytes + }); + + // Clean up temp file + try { fs.unlinkSync(filePath); } catch (e) {} + } catch (e) { + sendProgress('error', `Failed to read download result file: ${e instanceof Error ? e.message : String(e)}`); + } + } else if (line.startsWith('DOWNLOAD_RESULT:')) { + // Keep old method as fallback for smaller files + try { + const jsonString = line.substring(16); + const downloadData = JSON.parse(jsonString); + fileContent = downloadData.content; + filename = downloadData.filename; + sendProgress('success', 'Download completed!', { + filename, + size: Math.round((fileContent?.length || 0) * 0.75) // Rough base64 to bytes + }); + } catch (e) { + sendProgress('error', `Failed to parse download result: ${e instanceof Error ? e.message : String(e)}`); + } + } + } + }); + + child.stderr?.on('data', (data) => { + const output = data.toString(); + // Handle our direct log messages separately from real errors + if (output.includes('DIRECT_LOG:')) { + const message = output.replace('DIRECT_LOG:', '').trim(); + sendProgress('info', message); + } else { + sendProgress('error', `Error: ${output}`); + } + }); + + child.on('close', (code) => { + // Clean up + try { + fs.unlinkSync(tempScriptPath); + fs.unlinkSync(tempEnvPath); + } catch (err) {} + + sendProgress('debug', `Process closed with code: ${code}`); + sendProgress('debug', `File content available: ${!!fileContent}`); + sendProgress('debug', `Filename: ${filename || 'none'}`); + + // Check backup global variables if local ones are empty + if (!fileContent && (global as any).FINAL_DOWNLOAD_CONTENT) { + fileContent = (global as any).FINAL_DOWNLOAD_CONTENT; + filename = (global as any).FINAL_DOWNLOAD_FILENAME; + sendProgress('debug', `Using backup global variables - content length: ${fileContent?.length || 0}, filename: ${filename || 'none'}`); + } + + if (downloadCompleted) { + // Download was already processed successfully via DOWNLOAD_RESULT protocol + sendProgress('debug', 'Download already completed via DOWNLOAD_RESULT protocol'); + } else if (code === 0 && fileContent) { + try { + // Read content from temp file if it's a file path, otherwise treat as direct content + let actualContent: string; + if (fileContent.startsWith('/') && fs.existsSync(fileContent)) { + // Read from temp file + actualContent = fs.readFileSync(fileContent, 'utf8'); + // Clean up temp file + fs.unlinkSync(fileContent); + } else { + // Direct content (fallback) + actualContent = fileContent; + } + + // Simple completion message - file is already saved locally by the recipe + sendProgress('success', `File "${filename}" saved successfully!`, { + filename: filename, + localPath: path.resolve('downloaded-files', filename || 'download'), + size: Math.round(actualContent.length * 0.75) // Rough base64 to bytes + }); + } catch (err) { + sendProgress('error', `Failed to process download file: ${err instanceof Error ? err.message : String(err)}`); + } + } else if (code !== 0) { + sendProgress('error', `Process exited with code ${code}`); + } else if (!fileContent && !downloadCompleted) { + sendProgress('error', 'No file content captured'); + } + + controller.close(); + }); + + child.on('error', (error) => { + sendProgress('error', `Execution error: ${error.message}`); + controller.close(); + }); + + } catch (error) { + sendProgress('error', `Failed to start download: ${error}`); + controller.close(); + } +} + +function getContentTypeFromFilename(filename: string): string { + if (filename.endsWith('.pdf')) return 'application/pdf'; + if (filename.endsWith('.csv')) return 'text/csv'; + if (filename.endsWith('.json')) return 'application/json'; + return 'application/octet-stream'; +} \ No newline at end of file diff --git a/recipe-portal/app/api/download/route.ts b/recipe-portal/app/api/download/route.ts new file mode 100644 index 00000000..b7818336 --- /dev/null +++ b/recipe-portal/app/api/download/route.ts @@ -0,0 +1,408 @@ +import { NextResponse } from 'next/server'; +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +export async function POST(request: Request) { + try { + const { filePath, envVariables, filename, contentType } = await request.json(); + + if (!filePath) { + return NextResponse.json( + { error: 'File path is required' }, + { status: 400 } + ); + } + + // Security check: ensure the file is within the recipes directory + const recipesPath = path.join(process.cwd(), 'recipes'); + const resolvedPath = path.resolve(filePath); + const resolvedRecipesPath = path.resolve(recipesPath); + + if (!resolvedPath.startsWith(resolvedRecipesPath)) { + return NextResponse.json( + { error: 'Access denied: File must be within recipes directory' }, + { status: 403 } + ); + } + + // Check if file exists + if (!fs.existsSync(resolvedPath)) { + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } + ); + } + + // Create temporary .env file with provided variables + const tempEnvPath = path.join(os.tmpdir(), `.env-${Date.now()}`); + let envContent = ''; + + if (envVariables && typeof envVariables === 'object') { + for (const [key, value] of Object.entries(envVariables)) { + if (typeof value === 'string') { + envContent += `${key}=${value}\n`; + } + } + } + + // Add common variables if not provided + if (envVariables && !envVariables.authURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `authURL=https://aws-api.sigmacomputing.com/v2/auth/token\n`; + } + if (envVariables && !envVariables.baseURL && (envVariables.CLIENT_ID || envVariables.SECRET)) { + envContent += `baseURL=https://aws-api.sigmacomputing.com/v2\n`; + } + + // Add the path to the env file in the content + envContent += `ENV_FILE_PATH=${tempEnvPath}\n`; + + fs.writeFileSync(tempEnvPath, envContent); + + // Execute the script and capture file content + const result = await executeDownloadScript(resolvedPath, tempEnvPath); + + // Clean up temp file + try { + fs.unlinkSync(tempEnvPath); + } catch (err) { + console.warn('Failed to cleanup temp env file:', err); + } + + if (result.success && result.fileContent) { + // Return the file content for browser download + return NextResponse.json({ + fileContent: result.fileContent, + filename: filename || 'download', + contentType: contentType || 'application/octet-stream', + success: true, + output: result.stdout, + timestamp: new Date().toISOString() + }); + } else { + return NextResponse.json({ + output: result.stdout, + error: result.stderr, + success: false, + timestamp: new Date().toISOString(), + httpStatus: 500, + httpStatusText: 'Download Failed' + }); + } + + } catch (error) { + console.error('Error executing download script:', error); + return NextResponse.json( + { error: 'Failed to execute download script' }, + { status: 500 } + ); + } +} + +function executeDownloadScript(scriptPath: string, envFilePath: string): Promise<{ + stdout: string; + stderr: string; + success: boolean; + fileContent?: string; +}> { + return new Promise((resolve) => { + const scriptDir = path.dirname(scriptPath); + const recipesRoot = path.join(scriptDir, '..'); + + // Create a wrapper script that captures file content instead of writing to disk + const scriptName = path.basename(scriptPath); + const wrapperScript = ` +// Change to the recipes directory for proper module resolution +process.chdir('${recipesRoot}'); + +// Import required modules +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set up environment variables from our temp file +const envContent = fs.readFileSync('${envFilePath}', 'utf-8'); +const envLines = envContent.split('\\n'); + +envLines.forEach(line => { + const match = line.match(/^([^=]+)=(.*)$/); + if (match) { + process.env[match[1]] = match[2]; + } +}); + +// File-based token caching +const TOKEN_CACHE_FILE = path.join(os.tmpdir(), 'sigma-portal-token.json'); + +function getCachedToken() { + try { + if (fs.existsSync(TOKEN_CACHE_FILE)) { + const tokenData = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8')); + const now = Date.now(); + + if (tokenData.expiresAt && now < tokenData.expiresAt) { + return tokenData.token; + } else { + fs.unlinkSync(TOKEN_CACHE_FILE); + } + } + } catch (error) { + // Ignore errors + } + return null; +} + +function cacheToken(token) { + try { + const tokenData = { + token: token, + expiresAt: Date.now() + (60 * 60 * 1000), + createdAt: Date.now() + }; + fs.writeFileSync(TOKEN_CACHE_FILE, JSON.stringify(tokenData)); + } catch (error) { + console.error('Failed to cache token:', error.message); + } +} + +// Global variable to capture file content for download +global.DOWNLOAD_CONTENT = null; +global.DOWNLOAD_FILENAME = null; + +// Override file writing functions to capture content +const originalWriteFileSync = fs.writeFileSync; +const originalCreateWriteStream = fs.createWriteStream; +const originalReadFileSync = fs.readFileSync; +const originalUnlinkSync = fs.unlinkSync; + +console.log('WRAPPER: Setting up filesystem overrides'); + +fs.writeFileSync = function(filePath, data, options) { + // For JSON files, capture the content + if (filePath.endsWith('.json')) { + global.DOWNLOAD_CONTENT = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + global.DOWNLOAD_FILENAME = path.basename(filePath); + console.log(\`Download ready: \${global.DOWNLOAD_FILENAME}\`); + return; + } + // For other files, use original behavior as fallback + return originalWriteFileSync.call(this, filePath, data, options); +}; + +// Override stream writing for binary files +fs.createWriteStream = function(filePath, options) { + console.log(\`WRAPPER: Intercepted createWriteStream for: \${path.basename(filePath)}\`); + global.DOWNLOAD_FILENAME = path.basename(filePath); + global.DOWNLOAD_FILEPATH = filePath; + + // Use a temporary file to capture the actual data + const tempFilePath = filePath + '.temp'; + const realStream = originalCreateWriteStream.call(this, tempFilePath, options); + + // Create a proper writable stream proxy that captures completion + const mockStream = { + write: function(chunk) { + return realStream.write(chunk); + }, + end: function(chunk) { + if (chunk) realStream.write(chunk); + return realStream.end(); + }, + destroy: function() { + return realStream.destroy(); + }, + on: function(event, callback) { + if (event === 'finish') { + // When the real stream finishes, read the file and store content + realStream.on('finish', () => { + console.log(\`WRAPPER: Reading completed file...\`); + try { + // Give the filesystem a moment to flush + setTimeout(() => { + if (fs.existsSync(tempFilePath)) { + const fileData = originalReadFileSync(tempFilePath); + global.DOWNLOAD_CONTENT = fileData.toString('base64'); + console.log(\`WRAPPER: Successfully captured \${fileData.length} bytes\`); + console.log(\`Download ready: \${global.DOWNLOAD_FILENAME}\`); + // Clean up temp file + try { originalUnlinkSync(tempFilePath); } catch (e) {} + } else { + console.error(\`WRAPPER: Temp file missing: \${tempFilePath}\`); + } + // Always call the callback to let the recipe know we're done + if (callback) callback(); + }, 200); + } catch (err) { + console.error('WRAPPER: Error reading file:', err); + if (callback) callback(); + } + }); + return this; + } + return realStream.on(event, callback); + }, + // Implement writable stream interface properly + writable: true, + readable: false, + close: function() { + return realStream.close(); + } + }; + + return mockStream; +}; + +// Override getBearerToken function for cached tokens +async function getBearerToken() { + const cached = getCachedToken(); + if (cached) { + return cached; + } + + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + console.log = () => {}; + console.error = () => {}; + + const originalGetBearerToken = require('${recipesRoot}/get-access-token'); + const newToken = await originalGetBearerToken(); + + console.log = originalConsoleLog; + console.error = originalConsoleError; + + if (newToken) { + cacheToken(newToken); + } + + return newToken; +} + +// Execute the script +try { + const cachedToken = getCachedToken(); + + if (cachedToken) { + console.log('Using cached authentication token for download'); + + let scriptContent = fs.readFileSync('${scriptPath}', 'utf-8'); + + // Replace the getBearerToken import with cached token + const modifiedScript = scriptContent.replace( + /const getBearerToken = require\\(['"][^'"]*get-access-token['"]\\);/g, + 'const getBearerToken = async () => { return "' + cachedToken + '"; };' + ).replace( + /if \\(require\\.main === module\\) \\{([\\s\\S]*?)\\}/g, + '{ $1 }' + ); + + const tempScriptPath = '${scriptPath}' + '.download.js'; + fs.writeFileSync(tempScriptPath, modifiedScript); + + try { + delete require.cache[require.resolve(tempScriptPath)]; + require(tempScriptPath); + + // Wait longer for async operations to complete (PDF exports can take time) + let checkCount = 0; + const maxChecks = 30; // 30 checks * 2 seconds = 60 seconds max + + const checkForCompletion = () => { + checkCount++; + if (global.DOWNLOAD_CONTENT) { + console.log('DOWNLOAD_RESULT:' + JSON.stringify({ + content: global.DOWNLOAD_CONTENT, + filename: global.DOWNLOAD_FILENAME + })); + process.exit(0); + } else if (checkCount >= maxChecks) { + console.log('Download timeout - export may have failed or taken too long'); + process.exit(1); + } else { + // Check again in 2 seconds + setTimeout(checkForCompletion, 2000); + } + }; + + // Start checking after initial delay + setTimeout(checkForCompletion, 3000); + + } finally { + try { + fs.unlinkSync(tempScriptPath); + } catch (err) {} + } + } else { + console.log('No cached token found for download script.'); + process.exit(1); + } +} catch (error) { + console.error('Script execution error:', error.message); + process.exit(1); +} +`; + + const tempScriptPath = path.join(os.tmpdir(), `temp-download-wrapper-${Date.now()}.js`); + fs.writeFileSync(tempScriptPath, wrapperScript); + + const child = spawn('node', [tempScriptPath], { + cwd: recipesRoot, + timeout: 120000, // 120 second timeout for downloads (PDF exports can take time) + }); + + let stdout = ''; + let stderr = ''; + let fileContent: string | null = null; + + child.stdout?.on('data', (data) => { + const output = data.toString(); + stdout += output; + + // Look for download result in output + const downloadMatch = output.match(/DOWNLOAD_RESULT:(.+)/); + if (downloadMatch) { + try { + const downloadData = JSON.parse(downloadMatch[1]); + fileContent = downloadData.content; + } catch (e) { + console.error('Failed to parse download result:', e); + } + } + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + // Clean up temp script file + try { + fs.unlinkSync(tempScriptPath); + } catch (err) { + console.warn('Failed to cleanup temp script file:', err); + } + + resolve({ + stdout: stdout || 'Download script executed', + stderr: stderr || '', + success: code === 0 && fileContent !== null, + fileContent: fileContent || undefined + }); + }); + + child.on('error', (error) => { + // Clean up temp script file + try { + fs.unlinkSync(tempScriptPath); + } catch (err) { + console.warn('Failed to cleanup temp script file:', err); + } + + resolve({ + stdout: '', + stderr: `Execution error: ${error.message}`, + success: false + }); + }); + }); +} \ No newline at end of file diff --git a/recipe-portal/app/api/env/route.ts b/recipe-portal/app/api/env/route.ts index 7139cf70..f2cdde4c 100644 --- a/recipe-portal/app/api/env/route.ts +++ b/recipe-portal/app/api/env/route.ts @@ -4,7 +4,7 @@ import path from 'path'; export async function GET() { try { - const envFilePath = path.join(process.cwd(), '..', 'sigma-api-recipes', '.env'); + const envFilePath = path.join(process.cwd(), 'recipes', '.env'); if (!fs.existsSync(envFilePath)) { return NextResponse.json({ diff --git a/recipe-portal/app/api/execute/route.ts b/recipe-portal/app/api/execute/route.ts index 826ec13b..3969e0dd 100644 --- a/recipe-portal/app/api/execute/route.ts +++ b/recipe-portal/app/api/execute/route.ts @@ -15,14 +15,14 @@ export async function POST(request: Request) { ); } - // Security check: ensure the file is within the sigma-api-recipes directory - const recipesPath = path.join(process.cwd(), '..', 'sigma-api-recipes'); + // Security check: ensure the file is within the recipes directory + const recipesPath = path.join(process.cwd(), 'recipes'); const resolvedPath = path.resolve(filePath); const resolvedRecipesPath = path.resolve(recipesPath); if (!resolvedPath.startsWith(resolvedRecipesPath)) { return NextResponse.json( - { error: 'Access denied: File must be within sigma-api-recipes directory' }, + { error: 'Access denied: File must be within recipes directory' }, { status: 403 } ); } @@ -61,7 +61,7 @@ export async function POST(request: Request) { fs.writeFileSync(tempEnvPath, envContent); // Execute the script with timeout - const output = await executeScript(resolvedPath, tempEnvPath); + const output = await executeScript(resolvedPath, tempEnvPath, envVariables?.CLIENT_ID); // Clean up temp file try { @@ -89,7 +89,7 @@ export async function POST(request: Request) { } } -function executeScript(scriptPath: string, envFilePath: string): Promise<{ +function executeScript(scriptPath: string, envFilePath: string, clientId: string = null): Promise<{ stdout: string; stderr: string; success: boolean; @@ -100,6 +100,7 @@ function executeScript(scriptPath: string, envFilePath: string): Promise<{ // Create a wrapper script that handles module resolution and environment setup const scriptName = path.basename(scriptPath); + const isMasterScript = scriptPath.includes('master-script.js'); const wrapperScript = ` // Change to the recipes directory for proper module resolution process.chdir('${recipesRoot}'); @@ -120,11 +121,16 @@ envLines.forEach(line => { } }); -// File-based token caching -const TOKEN_CACHE_FILE = path.join(os.tmpdir(), 'sigma-portal-token.json'); +// Configuration-specific token caching +function getTokenCacheFile(clientId) { + // Create a safe filename using first 8 chars of clientId + const configHash = clientId ? clientId.substring(0, 8) : 'default'; + return path.join(os.tmpdir(), 'sigma-portal-token-' + configHash + '.json'); +} -function getCachedToken() { +function getCachedToken(clientId = null) { try { + const TOKEN_CACHE_FILE = getTokenCacheFile(clientId); if (fs.existsSync(TOKEN_CACHE_FILE)) { const tokenData = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8')); const now = Date.now(); @@ -143,10 +149,12 @@ function getCachedToken() { return null; } -function cacheToken(token) { +function cacheToken(token, clientId = null) { try { + const TOKEN_CACHE_FILE = getTokenCacheFile(clientId); const tokenData = { token: token, + clientId: clientId, expiresAt: Date.now() + (60 * 60 * 1000), // 1 hour from now createdAt: Date.now() }; @@ -157,9 +165,9 @@ function cacheToken(token) { } // Override getBearerToken function for recipes that use cached tokens -async function getBearerToken() { +async function getBearerToken(clientId = null) { // First check for cached token - const cached = getCachedToken(); + const cached = getCachedToken(clientId); if (cached) { // Don't log anything about tokens in regular recipes return cached; @@ -199,7 +207,7 @@ try { console.log('HTTP Status: 200 OK - Authentication successful'); // Cache the token for future use - cacheToken(token); + cacheToken(token, '${clientId}'); } else { console.log('āŒ Failed to obtain bearer token'); process.exit(1); @@ -210,7 +218,7 @@ try { }); ` : ` // For regular scripts, check for cached token first - const cachedToken = getCachedToken(); + const cachedToken = getCachedToken('${clientId}'); if (cachedToken) { console.log('Using cached authentication token'); @@ -229,9 +237,29 @@ try { '{ $1 }' // Remove the require.main check so the script always executes ); + // For master-script.js, we need to override the get-access-token module globally + // so that when sub-scripts import it, they get the cached token + const isMasterScript = '${scriptPath}'.includes('master-script.js'); + const finalScript = isMasterScript ? + '// Override get-access-token module globally for sub-scripts\\n' + + 'const Module = require(\\'module\\');\\n' + + 'const originalRequire = Module.prototype.require;\\n' + + '\\n' + + 'Module.prototype.require = function(id) {\\n' + + ' if (id === \\'../get-access-token\\' || id.endsWith(\\'get-access-token\\')) {\\n' + + ' return async () => {\\n' + + ' console.log("Using master script cached token for sub-operation");\\n' + + ' return "' + cachedToken + '";\\n' + + ' };\\n' + + ' }\\n' + + ' return originalRequire.apply(this, arguments);\\n' + + '};\\n' + + '\\n' + + modifiedScript : modifiedScript; + // Write to a temporary file and require it const tempScriptPath = '${scriptPath}' + '.cached.js'; - fs.writeFileSync(tempScriptPath, modifiedScript); + fs.writeFileSync(tempScriptPath, finalScript); try { // Clear require cache to ensure fresh execution @@ -264,9 +292,13 @@ try { const tempScriptPath = path.join(os.tmpdir(), `temp-wrapper-${Date.now()}.js`); fs.writeFileSync(tempScriptPath, wrapperScript); + // Set timeout based on script type - materialization takes longer + const isMaterializationScript = scriptPath.includes('initiate-materialization.js'); + const timeout = isMaterializationScript ? 300000 : 30000; // 5 minutes for materialization, 30 seconds for others + const child = spawn('node', [tempScriptPath], { cwd: recipesRoot, - timeout: 30000, // 30 second timeout + timeout: timeout, }); let stdout = ''; diff --git a/recipe-portal/app/api/keys/route.ts b/recipe-portal/app/api/keys/route.ts new file mode 100644 index 00000000..7d22e041 --- /dev/null +++ b/recipe-portal/app/api/keys/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from 'next/server'; +import { + storeCredentials, + getStoredCredentials, + hasStoredCredentials, + clearStoredCredentials, + getStoredCredentialNames, + getDefaultCredentialSetName, + setDefaultCredentialSet +} from '../../../lib/keyStorage'; + +// GET - Check if stored credentials exist and optionally retrieve them +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const retrieve = searchParams.get('retrieve') === 'true'; + const list = searchParams.get('list') === 'true'; + const setName = searchParams.get('set'); + + const hasKeys = await hasStoredCredentials(); + + if (!hasKeys) { + return NextResponse.json({ + hasStoredKeys: false, + credentials: null, + credentialSets: [], + defaultSet: null + }); + } + + const credentialSets = await getStoredCredentialNames(); + const defaultSet = await getDefaultCredentialSetName(); + + if (list) { + // Return list of available sets + return NextResponse.json({ + hasStoredKeys: true, + credentialSets, + defaultSet, + credentials: null + }); + } + + if (retrieve) { + const credentials = await getStoredCredentials(setName || undefined); + return NextResponse.json({ + hasStoredKeys: true, + credentials: credentials || null, + credentialSets, + defaultSet + }); + } + + return NextResponse.json({ + hasStoredKeys: true, + credentials: null, + credentialSets, + defaultSet + }); + + } catch (error) { + console.error('Error checking stored keys:', error); + return NextResponse.json( + { error: 'Failed to check stored credentials' }, + { status: 500 } + ); + } +} + +// POST - Store configuration (credentials + server settings) +export async function POST(request: Request) { + try { + const { clientId, clientSecret, name, setAsDefault, baseURL, authURL } = await request.json(); + + if (!clientId || !clientSecret) { + return NextResponse.json( + { error: 'Client ID and Client Secret are required' }, + { status: 400 } + ); + } + + if (!name || name.trim() === '') { + return NextResponse.json( + { error: 'Credential set name is required' }, + { status: 400 } + ); + } + const credentialSetName = name.trim(); + const success = await storeCredentials(clientId, clientSecret, credentialSetName, baseURL, authURL); + + // Set as default if requested + if (success && setAsDefault) { + await setDefaultCredentialSet(credentialSetName); + } + + if (success) { + return NextResponse.json({ + success: true, + message: 'Credentials stored successfully' + }); + } else { + return NextResponse.json( + { error: 'Failed to store credentials' }, + { status: 500 } + ); + } + + } catch (error) { + console.error('Error storing credentials:', error); + return NextResponse.json( + { error: 'Failed to store credentials' }, + { status: 500 } + ); + } +} + +// DELETE - Clear stored credentials (all or specific config) +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url); + const configName = searchParams.get('config'); + + const success = await clearStoredCredentials(configName || undefined); + + if (success) { + const message = configName + ? `Config "${configName}" deleted successfully` + : 'All stored credentials cleared successfully'; + + return NextResponse.json({ + success: true, + message + }); + } else { + return NextResponse.json( + { error: 'Failed to clear stored credentials' }, + { status: 500 } + ); + } + + } catch (error) { + console.error('Error clearing stored credentials:', error); + return NextResponse.json( + { error: 'Failed to clear stored credentials' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/open-folder/route.ts b/recipe-portal/app/api/open-folder/route.ts new file mode 100644 index 00000000..53a9b454 --- /dev/null +++ b/recipe-portal/app/api/open-folder/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { exec } from 'child_process'; +import path from 'path'; + +export async function POST(request: Request) { + try { + const { folder } = await request.json(); + + if (!folder || folder !== 'downloaded-files') { + return NextResponse.json( + { error: 'Invalid folder specified' }, + { status: 400 } + ); + } + + const folderPath = path.resolve(folder); + + // Open folder using system command based on OS + let command: string; + if (process.platform === 'win32') { + command = `explorer "${folderPath}"`; + } else if (process.platform === 'darwin') { + command = `open "${folderPath}"`; + } else { + command = `xdg-open "${folderPath}"`; + } + + exec(command, (error) => { + if (error) { + console.error('Error opening folder:', error); + } + }); + + return NextResponse.json({ success: true }); + + } catch (error) { + console.error('Error opening folder:', error); + return NextResponse.json( + { error: 'Failed to open folder' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/readme/route.ts b/recipe-portal/app/api/readme/route.ts index e399d04e..bd5c6eb5 100644 --- a/recipe-portal/app/api/readme/route.ts +++ b/recipe-portal/app/api/readme/route.ts @@ -2,10 +2,61 @@ import { NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; +function convertMarkdownToHtml(markdown: string): string { + // First, normalize line endings and remove excessive whitespace + let html = markdown + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + // Remove excessive blank lines + .replace(/\n{3,}/g, '\n\n') + // Trim each line + .split('\n') + .map(line => line.trim()) + .join('\n'); + + return html + // Code blocks (do this first to preserve their content) + .replace(/```[\s\S]*?```/g, (match) => { + const code = match.replace(/```\w*\n?/, '').replace(/\n?```$/, ''); + return `
${code.replace(//g, '>')}
`; + }) + // Headers + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Bold text + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Lists + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/^\* (.+)$/gm, '
  • $1
  • ') + .replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • ') + // Wrap consecutive list items in proper containers + .replace(/(
  • .*?<\/li>(?:\s*
  • .*?<\/li>)*)/g, (match) => { + const items = match.trim(); + return `
      ${items}
    `; + }) + // Convert double line breaks to paragraph breaks + .replace(/\n\s*\n/g, '

    ') + // Wrap remaining content in paragraphs + .replace(/^(?![<])/gm, '

    ') + // Clean up paragraph wrapping around headers and other elements + .replace(/

    (<[h123]||<\/pre>|<\/ul>)

    /g, '$1') + // Remove trailing paragraph tags + .replace(/<\/p>$/g, '') + // Remove empty paragraphs + .replace(/

    <\/p>/g, ''); +} + export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); const readmePath = searchParams.get('path'); + const format = searchParams.get('format'); // Check if HTML format is requested if (!readmePath) { return NextResponse.json( @@ -14,14 +65,16 @@ export async function GET(request: Request) { ); } - // Security check: ensure the file is within the sigma-api-recipes directory - const recipesPath = path.join(process.cwd(), '..', 'sigma-api-recipes'); + // Security check: ensure the file is within the recipes directory or is the main README + const recipesPath = path.join(process.cwd(), 'recipes'); + const mainReadmePath = path.join(process.cwd(), 'README.md'); const resolvedPath = path.resolve(readmePath); const resolvedRecipesPath = path.resolve(recipesPath); + const resolvedMainReadmePath = path.resolve(mainReadmePath); - if (!resolvedPath.startsWith(resolvedRecipesPath)) { + if (!resolvedPath.startsWith(resolvedRecipesPath) && resolvedPath !== resolvedMainReadmePath) { return NextResponse.json( - { error: 'Access denied: File must be within sigma-api-recipes directory' }, + { error: 'Access denied: File must be within recipes directory or be the main README' }, { status: 403 } ); } @@ -36,6 +89,70 @@ export async function GET(request: Request) { const content = fs.readFileSync(resolvedPath, 'utf-8'); + // If accessed directly in browser (no explicit JSON format requested), return HTML + if (format !== 'json') { + const htmlContent = ` + + + Recipe Instructions + + + + + āœ• Close +

    ${convertMarkdownToHtml(content)}
    + +`; + + return new NextResponse(htmlContent, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + }, + }); + } + + // Return JSON for API calls return NextResponse.json({ content, success: true diff --git a/recipe-portal/app/api/resources/route.ts b/recipe-portal/app/api/resources/route.ts new file mode 100644 index 00000000..ac45f521 --- /dev/null +++ b/recipe-portal/app/api/resources/route.ts @@ -0,0 +1,281 @@ +import { NextResponse } from 'next/server'; +import axios from 'axios'; + +// Base resource fetching function +async function fetchWithAuth(endpoint: string, token: string) { + try { + const baseURL = process.env.SIGMA_BASE_URL || 'https://aws-api.sigmacomputing.com/v2'; + const url = `${baseURL}${endpoint}`; + console.log(`Fetching: ${url}`); + const response = await axios.get(url, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + }); + console.log(`Response status for ${endpoint}:`, response.status); + return response.data; + } catch (error) { + console.error(`Error fetching ${endpoint}:`, (error as any).response?.data || (error as any).message); + throw error; + } +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const type = searchParams.get('type'); + const token = searchParams.get('token'); + + if (!token) { + return NextResponse.json( + { error: 'Authentication token is required' }, + { status: 401 } + ); + } + + if (!type) { + return NextResponse.json( + { error: 'Resource type is required. Use: teams, members, workbooks, connections, workspaces, bookmarks, templates, datasets, dataModels, accountTypes, workbookElements, materializationSchedules' }, + { status: 400 } + ); + } + + let data: any; + let transformedData: any[]; + + switch (type) { + case 'teams': + data = await fetchWithAuth('/teams', token); + transformedData = (data.entries || data).map((team: any) => ({ + id: team.teamId, + name: team.name, + description: team.description || '', + memberCount: team.memberCount || 0 + })); + break; + + case 'members': + data = await fetchWithAuth('/members', token); + // Filter out potentially inactive members and map to display format + const activeMembers = (data.entries || data).filter((member: any) => { + // Add filters for inactive members based on patterns you identify + // For now, keeping all members - you can modify this filter + return true; + }); + + transformedData = activeMembers.map((member: any) => ({ + id: member.memberId, + name: `${member.firstName} ${member.lastName}`.trim(), + email: member.email, + firstName: member.firstName, + lastName: member.lastName, + type: member.memberType + })); + break; + + case 'workbooks': + data = await fetchWithAuth('/workbooks', token); + transformedData = (data.entries || data).map((workbook: any) => ({ + id: workbook.workbookId, + name: workbook.name, + path: workbook.path, + ownerId: workbook.ownerId, + createdBy: workbook.createdBy, + url: workbook.url + })); + break; + + case 'connections': + data = await fetchWithAuth('/connections', token); + transformedData = (data.entries || data).map((connection: any) => ({ + id: connection.connectionId, + name: connection.name, + type: connection.type, + description: connection.description || '' + })); + break; + + case 'workspaces': + data = await fetchWithAuth('/workspaces', token); + transformedData = (data.entries || data).map((workspace: any) => ({ + id: workspace.workspaceId, + name: workspace.name, + description: workspace.description || '' + })); + break; + + case 'bookmarks': + // Using favorites endpoint since bookmarks API maps to favorites + data = await fetchWithAuth('/favorites', token); + transformedData = (data.entries || data).map((favorite: any) => ({ + id: favorite.favoriteId || favorite.inodeId, + name: favorite.name || favorite.title, + description: favorite.description || '', + type: favorite.type || 'favorite', + url: favorite.url + })); + break; + + case 'templates': + data = await fetchWithAuth('/templates', token); + transformedData = (data.entries || data).map((template: any) => ({ + id: template.templateId, + name: template.name, + description: template.description || '', + type: template.type + })); + break; + + case 'datasets': + data = await fetchWithAuth('/datasets', token); + transformedData = (data.entries || data).map((dataset: any) => ({ + id: dataset.datasetId, + name: dataset.name, + description: dataset.description || '', + type: dataset.type + })); + break; + + case 'dataModels': + data = await fetchWithAuth('/dataModels', token); + transformedData = (data.entries || data).map((dataModel: any) => ({ + id: dataModel.dataModelId, + name: dataModel.name, + description: dataModel.description || '', + type: dataModel.type || 'dataModel' + })); + break; + + case 'accountTypes': + data = await fetchWithAuth('/accountTypes', token); + console.log('AccountTypes raw data:', JSON.stringify(data, null, 2)); + transformedData = (data.entries || data).map((accountType: any) => ({ + id: accountType.accountTypeName, + name: accountType.accountTypeName, + description: accountType.description || '', + type: accountType.isCustom ? 'custom' : 'built-in', + isCustom: accountType.isCustom + })); + break; + + case 'workbookElements': + const workbookId = searchParams.get('workbookId'); + if (!workbookId) { + return NextResponse.json( + { error: 'workbookId parameter is required for workbookElements' }, + { status: 400 } + ); + } + + try { + // First, get all pages from the workbook + console.log(`Fetching pages for workbook: ${workbookId}`); + const pagesData = await fetchWithAuth(`/workbooks/${workbookId}/pages`, token); + console.log('Pages data:', JSON.stringify(pagesData, null, 2)); + + const pages = pagesData.entries || pagesData || []; + let allElements: any[] = []; + + // For each page, get its elements + for (const page of pages) { + const pageId = page.pageId || page.id; + if (pageId) { + try { + console.log(`Fetching elements for page: ${pageId}`); + const elementsData = await fetchWithAuth(`/workbooks/${workbookId}/pages/${pageId}/elements`, token); + console.log(`Elements data for page ${pageId}:`, JSON.stringify(elementsData, null, 2)); + + const pageElements = elementsData.entries || elementsData || []; + + // Add page information to each element + const elementsWithPageInfo = pageElements.map((element: any) => ({ + ...element, + pageId: pageId, + pageName: page.name || page.title || `Page ${pageId}` + })); + + allElements = allElements.concat(elementsWithPageInfo); + } catch (pageError) { + console.warn(`Failed to fetch elements for page ${pageId}:`, pageError); + // Continue with other pages even if one fails + } + } + } + + console.log('All extracted elements:', allElements); + + transformedData = allElements.map((element: any) => ({ + id: element.elementId || element.id || element.elementUid, + name: element.name || element.title || element.displayName || `${element.pageName} - ${element.name || element.title || element.displayName || 'Unnamed Element'}`, + type: element.type || element.elementType || 'element', + description: element.description || `Element on page: ${element.pageName}`, + pageId: element.pageId, + pageName: element.pageName + })); + + } catch (error) { + console.error('Error fetching workbook elements:', error); + transformedData = []; + } + + console.log('Final transformed elements data:', transformedData); + break; + + case 'materializationSchedules': + const workbookIdForMat = searchParams.get('workbookId'); + if (!workbookIdForMat) { + return NextResponse.json( + { error: 'workbookId parameter is required for materializationSchedules' }, + { status: 400 } + ); + } + + try { + console.log(`Fetching materialization schedules for workbook: ${workbookIdForMat}`); + const schedulesData = await fetchWithAuth(`/workbooks/${workbookIdForMat}/materialization-schedules`, token); + console.log('Materialization schedules data:', JSON.stringify(schedulesData, null, 2)); + + const schedules = schedulesData.entries || schedulesData || []; + + transformedData = schedules.map((schedule: any) => ({ + id: schedule.sheetId, // Use sheetId as the value that will be sent to the script + name: schedule.elementName, // Display the element name to the user + description: `${schedule.schedule.cronSpec} ${schedule.schedule.timezone}${schedule.paused ? ' - PAUSED' : ''}`, + type: 'materializationSchedule', + sheetId: schedule.sheetId, + elementName: schedule.elementName, + cronSpec: schedule.schedule.cronSpec, + timezone: schedule.schedule.timezone, + paused: schedule.paused + })); + + } catch (error) { + console.error('Error fetching materialization schedules:', error); + transformedData = []; + } + + console.log('Final transformed schedules data:', transformedData); + break; + + default: + return NextResponse.json( + { error: `Unsupported resource type: ${type}` }, + { status: 400 } + ); + } + + return NextResponse.json({ + type, + count: transformedData.length, + data: transformedData.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')) + }); + + } catch (error) { + console.error('Error in resources API:', error); + return NextResponse.json( + { error: 'Failed to fetch resources' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/token/clear/route.ts b/recipe-portal/app/api/token/clear/route.ts new file mode 100644 index 00000000..4c0f4b01 --- /dev/null +++ b/recipe-portal/app/api/token/clear/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Configuration-specific token caching +function getTokenCacheFile(clientId) { + // Create a safe filename using first 8 chars of clientId + const configHash = clientId ? clientId.substring(0, 8) : 'default'; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +export async function POST(request: Request) { + try { + const { clientId, clearAll } = await request.json(); + + console.log('Token clear request:', { clientId, clearAll }); + + if (clearAll) { + // Clear all token cache files + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + console.log('Clearing all tokens:', tokenFiles); + + let clearedCount = 0; + for (const file of tokenFiles) { + try { + fs.unlinkSync(path.join(tempDir, file)); + clearedCount++; + console.log(`Cleared token file: ${file}`); + } catch (err) { + console.warn(`Failed to delete token file ${file}:`, err); + } + } + + return NextResponse.json({ + success: true, + message: `Cleared ${clearedCount} authentication token(s)` + }); + } else { + // Clear specific configuration's token + const TOKEN_CACHE_FILE = getTokenCacheFile(clientId); + + if (fs.existsSync(TOKEN_CACHE_FILE)) { + fs.unlinkSync(TOKEN_CACHE_FILE); + } + + return NextResponse.json({ + success: true, + message: 'Authentication token cleared successfully' + }); + } + } catch (error) { + console.error('Error clearing token:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to clear authentication token' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/token/route.ts b/recipe-portal/app/api/token/route.ts new file mode 100644 index 00000000..01e78b35 --- /dev/null +++ b/recipe-portal/app/api/token/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Configuration-specific token caching +function getTokenCacheFile(clientId: string) { + // Create a safe filename using first 8 chars of clientId + const configHash = clientId ? clientId.substring(0, 8) : 'default'; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +export async function GET() { + try { + // Look for the most recent valid token across all configurations + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + let mostRecentToken = null; + let mostRecentTime = 0; + + console.log('Found token files:', tokenFiles); + + for (const file of tokenFiles) { + try { + const filePath = path.join(tempDir, file); + const tokenData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const now = Date.now(); + + // Check if token is still valid (not expired) + if (tokenData.expiresAt && now < tokenData.expiresAt) { + // Use the most recently created/accessed token + const lastAccessTime = tokenData.lastAccessed || tokenData.createdAt; + console.log(`Token ${file}: clientId=${tokenData.clientId?.substring(0,8)}, createdAt=${new Date(tokenData.createdAt)}, lastAccessed=${tokenData.lastAccessed ? new Date(tokenData.lastAccessed) : 'none'}, lastAccessTime=${lastAccessTime}`); + + if (lastAccessTime > mostRecentTime) { + console.log(` -> This is the most recent token so far`); + mostRecentTime = lastAccessTime; + mostRecentToken = { + hasValidToken: true, + token: tokenData.token, + expiresAt: tokenData.expiresAt, + timeRemaining: Math.round((tokenData.expiresAt - now) / 1000 / 60), // minutes + clientId: tokenData.clientId, + filePath: filePath // Keep track of which file this came from + }; + } + } else { + // Token expired, remove file + fs.unlinkSync(filePath); + } + } catch (err) { + // Skip invalid token files + console.warn(`Failed to read token file ${file}:`, err); + } + } + + if (mostRecentToken) { + console.log(`Selected token: clientId=${mostRecentToken.clientId?.substring(0,8)}`); + + // Update the last accessed time for this token + try { + const tokenData = JSON.parse(fs.readFileSync(mostRecentToken.filePath, 'utf8')); + tokenData.lastAccessed = Date.now(); + fs.writeFileSync(mostRecentToken.filePath, JSON.stringify(tokenData)); + } catch (err) { + console.warn('Failed to update token access time:', err); + } + + // Remove filePath from response + const { filePath, ...responseData } = mostRecentToken; + return NextResponse.json(responseData); + } + + return NextResponse.json({ + hasValidToken: false, + token: null + }); + } catch (error) { + console.error('Error checking token:', error); + return NextResponse.json({ + hasValidToken: false, + token: null + }); + } +} \ No newline at end of file diff --git a/recipe-portal/app/layout.tsx b/recipe-portal/app/layout.tsx index c89b7deb..6b24a7ca 100644 --- a/recipe-portal/app/layout.tsx +++ b/recipe-portal/app/layout.tsx @@ -2,8 +2,13 @@ import type { Metadata } from 'next' import './globals.css' export const metadata: Metadata = { - title: 'Sigma API Recipe Portal', - description: 'Interactive portal for Sigma API recipes and examples', + title: 'QuickStarts API Toolkit', + description: 'Experiment with Sigma API calls and learn common request flows', + icons: { + icon: '/crane.png', + shortcut: '/crane.png', + apple: '/crane.png', + }, } export default function RootLayout({ diff --git a/recipe-portal/app/page.tsx b/recipe-portal/app/page.tsx index 52b5d35c..101a566a 100644 --- a/recipe-portal/app/page.tsx +++ b/recipe-portal/app/page.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { RecipeCard } from '../components/RecipeCard'; import { CodeViewer } from '../components/CodeViewer'; +import { QuickApiExplorer } from '../components/QuickApiExplorer'; interface Recipe { id: string; @@ -29,9 +30,36 @@ export default function Home() { const [recipeData, setRecipeData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [activeTopTab, setActiveTopTab] = useState<'recipes' | 'quickapi'>('recipes'); const [activeCategoryTab, setActiveCategoryTab] = useState(''); + const [authToken, setAuthToken] = useState(null); const [hasValidToken, setHasValidToken] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false); + const [clearingToken, setClearingToken] = useState(false); + const [quickApiKey, setQuickApiKey] = useState(0); + + // Function to check auth status (reusable) + const checkAuthStatus = useCallback(async () => { + try { + const response = await fetch('/api/token'); + if (response.ok) { + const data = await response.json(); + if (data.hasValidToken) { + setHasValidToken(true); + setAuthToken(data.token); + } else { + setHasValidToken(false); + setAuthToken(null); + } + } else { + setHasValidToken(false); + setAuthToken(null); + } + } catch (error) { + setHasValidToken(false); + setAuthToken(null); + } + }, []); useEffect(() => { async function fetchRecipes() { @@ -53,23 +81,43 @@ export default function Home() { } } - async function checkAuthStatus() { - try { - const response = await fetch('/api/token'); - if (response.ok) { - const data = await response.json(); - if (data.hasValidToken) { - setHasValidToken(true); - } + fetchRecipes(); + checkAuthStatus(); + }, [checkAuthStatus]); + + // Periodically check auth status every 30 seconds + useEffect(() => { + const interval = setInterval(checkAuthStatus, 30000); + return () => clearInterval(interval); + }, [checkAuthStatus]); + + const clearToken = async () => { + setClearingToken(true); + try { + const response = await fetch('/api/token/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ clearAll: true }) + }); + + if (response.ok) { + setHasValidToken(false); + setAuthToken(null); + // If auth modal is open, close it to trigger form reset on next open + if (showAuthModal) { + setShowAuthModal(false); } - } catch (error) { - // Ignore errors - just means no token is cached + } else { + console.error('Failed to clear token'); } + } catch (error) { + console.error('Error clearing token:', error); + } finally { + setClearingToken(false); } - - fetchRecipes(); - checkAuthStatus(); - }, []); + }; if (loading) { return ( @@ -109,74 +157,138 @@ export default function Home() {
    -

    - Sigma API Recipe Portal -

    +
    + Sigma Logo +

    + QuickStarts API Toolkit +

    +
    -

    - Interactive collection of JavaScript samples demonstrating how to use the Sigma API for specific use cases. +

    + Experiment with Sigma API calls and learn common request flows

    - {/* Auth Button */} -
    + {/* Action Buttons */} +
    + + {hasValidToken ? ( +
    + + +
    + ) : ( + + )}
    {/* Main Content Container */}
    - {/* Category Tabs */} -
    -
    ); -} \ No newline at end of file +} diff --git a/recipe-portal/app/test/page.tsx b/recipe-portal/app/test/page.tsx new file mode 100644 index 00000000..5f2bebef --- /dev/null +++ b/recipe-portal/app/test/page.tsx @@ -0,0 +1,8 @@ +export default function TestPage() { + return ( +
    +

    Test Page

    +

    If you can see this, routing is working.

    +
    + ); +} \ No newline at end of file diff --git a/recipe-portal/bug.png b/recipe-portal/bug.png new file mode 100644 index 0000000000000000000000000000000000000000..9bfc2286045253889609d2075da7e781183d1778 GIT binary patch literal 121989 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%0D(L=k3VhewNSR+nQ9N-9)#vk3qXH3es_IPc)u_ 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 literal 0 HcmV?d00001 diff --git a/recipe-portal/components/CodeViewer.tsx b/recipe-portal/components/CodeViewer.tsx index 15b64534..cf889b90 100644 --- a/recipe-portal/components/CodeViewer.tsx +++ b/recipe-portal/components/CodeViewer.tsx @@ -1,6 +1,8 @@ 'use client'; import { useState, useEffect } from 'react'; +import { detectSmartParameters, SmartParameter, analyzeRecipeCode } from '../lib/smartParameters'; +import { SmartParameterForm } from './SmartParameterForm'; interface CodeViewerProps { isOpen: boolean; @@ -10,19 +12,40 @@ interface CodeViewerProps { envVariables?: string[]; useEnvFile?: boolean; onTokenObtained?: () => void; + onTokenCleared?: () => void; defaultTab?: 'params' | 'run' | 'code' | 'readme'; + hasValidToken?: boolean; + readmePath?: string; } interface ExecutionResult { output: string; error: string; - success: boolean; + success: boolean | null; timestamp: string; httpStatus?: number; httpStatusText?: string; + downloadInfo?: { + filename: string; + localPath: string; + size: number; + }; } -export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = [], useEnvFile = false, onTokenObtained, defaultTab = 'params' }: CodeViewerProps) { +// Function to open the downloads folder via API +const openDownloadsFolder = async () => { + try { + await fetch('/api/open-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder: 'downloaded-files' }) + }); + } catch (error) { + console.log('Could not open folder automatically. Please navigate to the downloaded-files folder manually.'); + } +}; + +export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = [], useEnvFile = false, onTokenObtained, onTokenCleared, defaultTab = 'params', hasValidToken = false, readmePath }: CodeViewerProps) { const [code, setCode] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -31,7 +54,33 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = const [envFileValues, setEnvFileValues] = useState>({}); const [executing, setExecuting] = useState(false); const [executionResult, setExecutionResult] = useState(null); - const [detectedParameters, setDetectedParameters] = useState([]); + const [smartParameters, setSmartParameters] = useState([]); + const [authToken, setAuthToken] = useState(null); + const [clearingToken, setClearingToken] = useState(false); + const [storeKeysLocally, setStoreKeysLocally] = useState(false); + const [hasStoredKeys, setHasStoredKeys] = useState(false); + const [currentFormIsStored, setCurrentFormIsStored] = useState(false); + + // Reset form when modal is closed + useEffect(() => { + if (!isOpen) { + setEnvValues({}); + setExecutionResult(null); + setActiveTab(defaultTab); + setError(null); + setSetAsDefault(false); + setCopyButtonText('Copy Output'); + } + }, [isOpen, defaultTab]); + const [saveNotification, setSaveNotification] = useState(null); + const [credentialSetName, setCredentialSetName] = useState(''); + const [availableCredentialSets, setAvailableCredentialSets] = useState([]); + const [selectedCredentialSet, setSelectedCredentialSet] = useState(''); + const [setAsDefault, setSetAsDefault] = useState(false); + const [defaultCredentialSet, setDefaultCredentialSet] = useState(null); + const [copyButtonText, setCopyButtonText] = useState('Copy Output'); + const [customReadme, setCustomReadme] = useState(null); + const [readmeLoading, setReadmeLoading] = useState(false); useEffect(() => { if (isOpen && filePath) { @@ -40,20 +89,37 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = if (fileName === 'get-access-token.js') { // Auth script: README first smartDefaultTab = 'readme'; - } else if (detectedParameters.length > 0) { - // Has parameters: Parameters first + } else if (smartParameters.length > 0) { + // Has parameters: Request first smartDefaultTab = 'params'; } else { // No parameters: Run Script (Response) first smartDefaultTab = 'run'; } - setActiveTab(smartDefaultTab); + + // Only set the tab if it's not already set to avoid switching during execution + // Don't switch tabs if we're currently executing or if we have results to show + if (!executing && !executionResult && (activeTab === defaultTab || (activeTab === 'run' && smartParameters.length > 0))) { + setActiveTab(smartDefaultTab); + } fetchCode(); + checkAuthToken(); if (useEnvFile) { fetchEnvFile(); } + } else if (!isOpen) { + // Reset form when modal is closed + if (fileName === 'get-access-token.js') { + setEnvValues({ + 'baseURL': 'https://aws-api.sigmacomputing.com/v2', + 'authURL': 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'CLIENT_ID': '', + 'SECRET': '' + }); + } + setExecutionResult(null); } - }, [isOpen, filePath, useEnvFile, fileName, detectedParameters.length]); + }, [isOpen, filePath, useEnvFile, fileName, executing, smartParameters.length, executionResult]); // Set default auth values for authentication script useEffect(() => { @@ -64,24 +130,156 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = } }, [fileName, envValues]); - // Detect parameters when code changes + // Sync internal auth state with parent useEffect(() => { - if (code) { - const envVarPattern = /process\.env\.([A-Z_]+)/g; - const matches = code.match(envVarPattern) || []; + if (!hasValidToken) { + setAuthToken(null); - const parameters = new Set(); - matches.forEach(match => { - const paramName = match.replace('process.env.', ''); - // Filter out auth parameters since they're handled centrally - if (!['CLIENT_ID', 'SECRET', 'authURL', 'baseURL'].includes(paramName)) { - parameters.add(paramName); + // Clear form fields when session is ended from main page + if (fileName === 'get-access-token.js') { + setEnvValues({ + 'baseURL': 'https://aws-api.sigmacomputing.com/v2', + 'authURL': 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'CLIENT_ID': '', + 'SECRET': '' + }); + } + } + }, [hasValidToken, fileName]); + + // Load custom README if available + useEffect(() => { + if (readmePath && isOpen) { + setReadmeLoading(true); + fetch(`/api/readme?path=${encodeURIComponent(readmePath)}&format=json`) + .then(response => response.json()) + .then(data => { + if (data.success) { + setCustomReadme(data.content); + } + }) + .catch(error => { + console.error('Failed to load custom README:', error); + }) + .finally(() => { + setReadmeLoading(false); + }); + } else { + setCustomReadme(null); + } + }, [readmePath, isOpen]); + + // Detect smart parameters when code changes + useEffect(() => { + if (code) { + // Analyze code to find parameters + const analysis = analyzeRecipeCode(code, { filePath }); + const detected = detectSmartParameters(analysis.suggestedParameters, { filePath }); + setSmartParameters(detected); + } + }, [code, filePath]); + + // Check for stored credentials when auth modal opens + // Only auto-populate if form is empty (app startup scenario) + useEffect(() => { + const checkStoredCredentials = async () => { + if (isOpen && fileName === 'get-access-token.js') { + try { + const response = await fetch('/api/keys?retrieve=true'); + if (response.ok) { + const data = await response.json(); + setHasStoredKeys(data.hasStoredKeys); + setAvailableCredentialSets(data.credentialSets || []); + setDefaultCredentialSet(data.defaultSet || null); + + // Only auto-populate if fields are empty AND we have a valid token + // This prevents re-population after "End Session" is clicked + const hasEmptyFields = !envValues['CLIENT_ID'] && !envValues['SECRET']; + + if (data.hasStoredKeys && data.credentials && hasEmptyFields && hasValidToken) { + // Auto-populate form with complete config on startup + handleEnvChange('CLIENT_ID', data.credentials.clientId); + handleEnvChange('SECRET', data.credentials.clientSecret); + handleEnvChange('baseURL', data.credentials.baseURL); + handleEnvChange('authURL', data.credentials.authURL); + setStoreKeysLocally(true); // Check the checkbox since keys are stored + setSelectedCredentialSet(data.defaultSet || ''); + setCurrentFormIsStored(true); // Mark current form as representing stored data + } + } + } catch (error) { + console.log('Error checking stored credentials:', error); } + } + }; + + checkStoredCredentials(); + }, [isOpen, fileName]); + + const checkAuthToken = async () => { + try { + console.log('checkAuthToken: Fetching current token from /api/token'); + const response = await fetch('/api/token'); + if (response.ok) { + const data = await response.json(); + console.log('checkAuthToken: Response from /api/token:', { hasValidToken: data.hasValidToken, clientId: data.clientId?.substring(0,8) }); + if (data.hasValidToken && data.token) { + console.log('checkAuthToken: Updating authToken state'); + setAuthToken(data.token); + } + } + } catch (error) { + console.log('No cached token available'); + } + }; + + const clearToken = async () => { + setClearingToken(true); + try { + // Clear the session token + const response = await fetch('/api/token/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ clearAll: true }) }); - setDetectedParameters(Array.from(parameters)); + if (response.ok) { + setAuthToken(null); + + // Handle stored keys logic for auth script + if (fileName === 'get-access-token.js') { + if (!storeKeysLocally && hasStoredKeys) { + // User unchecked the box - clear stored keys + await fetch('/api/keys', { method: 'DELETE' }); + setHasStoredKeys(false); + } + + // Always clear form fields on End Session + // This implements the new UX flow: + // - Session-only: fields cleared + // - Storage enabled: fields cleared (will be restored on next startup) + setEnvValues({ + 'baseURL': 'https://aws-api.sigmacomputing.com/v2', + 'authURL': 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'CLIENT_ID': '', + 'SECRET': '' + }); + } + + if (onTokenCleared) { + onTokenCleared(); + } + } else { + console.error('Failed to clear token'); + } + } catch (error) { + console.error('Error clearing token:', error); + } finally { + setClearingToken(false); } - }, [code]); + }; const fetchCode = async () => { @@ -128,7 +326,240 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = } }; + const getDownloadFilename = (fileName: string, envValues: Record) => { + switch (fileName) { + case 'export-workbook-element-csv.js': + return envValues['EXPORT_FILENAME'] || 'export.csv'; + case 'export-workbook-pdf.js': + return 'workbook-export.pdf'; + default: + return 'download'; + } + }; + + const getDownloadContentType = (fileName: string) => { + switch (fileName) { + case 'export-workbook-element-csv.js': + return 'text/csv'; + case 'export-workbook-pdf.js': + return 'application/pdf'; + default: + return 'application/octet-stream'; + } + }; + + const createBlobFromContent = (content: string, contentType: string) => { + // All content from DOWNLOAD_RESULT protocol is base64 encoded + try { + const byteCharacters = atob(content); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + return new Blob([byteArray], { type: contentType }); + } catch (error) { + // Fallback for non-base64 content (shouldn't happen with new protocol) + console.warn('Failed to decode base64 content, treating as text:', error); + return new Blob([content], { type: contentType }); + } + }; + + const handleStreamingDownload = async (filePath: string, envVariables: Record, filename: string, contentType: string) => { + try { + const response = await fetch('/api/download-stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filePath, + envVariables, + filename, + contentType + }) + }); + + if (!response.ok) { + throw new Error('Failed to start download stream'); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body'); + } + + let outputMessages: string[] = []; + let jsonBuffer = ''; // Persistent buffer for handling split JSON messages + + // Initialize with starting message + const startingMessage = `${new Date().toLocaleTimeString()} - Starting export process...`; + outputMessages.push(startingMessage); + + setExecutionResult({ + output: startingMessage + '\n', + error: '', + success: null, // null indicates "in progress" + timestamp: new Date().toISOString() + }); + + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ') && line.trim() !== 'data: ') { + const jsonPart = line.substring(6); + jsonBuffer += jsonPart; + + // Try to parse the accumulated JSON + try { + const data = JSON.parse(jsonBuffer); + // Success! Reset buffer and process the data + jsonBuffer = ''; + const timestamp = new Date(data.timestamp).toLocaleTimeString(); + + // Add message to beginning of array (newest first) + // Show debug messages during development + const prefix = ''; + const newMessage = `${timestamp} - ${prefix}${data.message}`; + outputMessages.unshift(newMessage); + + // Keep only last 100 messages to see debug info + if (outputMessages.length > 100) { + outputMessages = outputMessages.slice(0, 100); + } + + // Update the execution result with progressive output (newest first) + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: null, // Keep as "in progress" until completion + timestamp: data.timestamp + }); + + // Handle download completion with folder link + if (data.type === 'success' && data.data && data.data.filename) { + // Create clickable message to open downloads folder + const folderMessage = `${timestamp} - šŸ“ File saved! Click here to open downloads folder`; + const fileInfo = `${timestamp} - āœ… ${data.data.filename} (${Math.round(data.data.size / 1024)}KB) saved to downloaded-files/`; + outputMessages.unshift(folderMessage); + outputMessages.unshift(fileInfo); + + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: true, + timestamp: data.timestamp, + downloadInfo: { + filename: data.data.filename, + localPath: data.data.localPath, + size: data.data.size + } + }); + + // Switch to Response tab to show the completion message + setActiveTab('run'); + } + + // Handle errors + if (data.type === 'error') { + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: data.message, + success: false, + timestamp: data.timestamp + }); + break; + } + + } catch (e) { + // JSON parsing failed - this might be a partial message + // Keep the buffer and wait for more data, but limit buffer size to prevent memory issues + if (jsonBuffer.length > 500000) { // 500KB limit + console.error('JSON buffer too large, discarding:', jsonBuffer.substring(0, 100) + '...'); + jsonBuffer = ''; + } + // Don't log every parse error as they're expected for partial messages + } + } else if (line.trim() === '' && jsonBuffer) { + // Empty line might indicate end of an SSE message - try to parse what we have + try { + const data = JSON.parse(jsonBuffer); + jsonBuffer = ''; // Reset on successful parse + + const timestamp = new Date(data.timestamp).toLocaleTimeString(); + const newMessage = `${timestamp} - ${data.message}`; + outputMessages.unshift(newMessage); + + if (outputMessages.length > 100) { + outputMessages = outputMessages.slice(0, 100); + } + + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: null, + timestamp: data.timestamp + }); + + // Handle download completion (same logic as above) + if (data.type === 'success' && data.data && data.data.filename) { + const folderMessage = `${timestamp} - šŸ“ File saved! Click here to open downloads folder`; + const fileInfo = `${timestamp} - āœ… ${data.data.filename} (${Math.round(data.data.size / 1024)}KB) saved to downloaded-files/`; + outputMessages.unshift(folderMessage); + outputMessages.unshift(fileInfo); + + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: true, + timestamp: data.timestamp, + downloadInfo: { + filename: data.data.filename, + localPath: data.data.localPath, + size: data.data.size + } + }); + + setActiveTab('run'); + } + + if (data.type === 'error') { + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: data.message, + success: false, + timestamp: data.timestamp + }); + return; // Exit the stream processing + } + + } catch (e) { + // Still couldn't parse - keep waiting for more data + } + } + } + } + + } catch (error) { + setExecutionResult({ + output: '', + error: error instanceof Error ? error.message : 'Unknown streaming error', + success: false, + timestamp: new Date().toISOString() + }); + } + }; + const executeScript = async () => { + console.log('executeScript called'); setExecuting(true); setExecutionResult(null); @@ -156,10 +587,12 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = }; // Validate that required auth credentials are provided (for auth script only) + console.log('Validating auth credentials:', { fileName, coreAuthVars }); if (fileName === 'get-access-token.js' && (!coreAuthVars.CLIENT_ID || !coreAuthVars.SECRET)) { + console.log('Validation failed - missing credentials'); setExecutionResult({ output: '', - error: 'Authentication required: Please provide CLIENT_ID and SECRET credentials in the Parameters tab.', + error: 'Authentication required: Please provide CLIENT_ID and SECRET credentials in the Config tab.', success: false, timestamp: new Date().toISOString(), httpStatus: 401, @@ -169,25 +602,94 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = return; } + console.log('Validation passed, continuing execution...'); + const allEnvVariables = { ...coreAuthVars, ...currentEnvValues }; + console.log('About to make API request with variables:', Object.keys(allEnvVariables)); + + - const response = await fetch('/api/execute', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - filePath, - envVariables: allEnvVariables - }) - }); + // Check if this is a download recipe + const isDownloadRecipe = ['export-workbook-element-csv.js', 'export-workbook-pdf.js'].includes(fileName); - const result = await response.json(); - setExecutionResult(result); + let result; + let response; - // If this is an auth script and execution was successful, notify parent + if (isDownloadRecipe) { + // Handle download recipes with streaming progress + await handleStreamingDownload(filePath, allEnvVariables, getDownloadFilename(fileName, currentEnvValues), getDownloadContentType(fileName)); + return; // Exit early since streaming handles everything + } else { + // Handle regular recipes + response = await fetch('/api/execute', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filePath, + envVariables: allEnvVariables + }) + }); + + console.log('API response received:', response.status, response.statusText); + + result = await response.json(); + console.log('API result:', result); + setExecutionResult(result); + console.log('ExecutionResult set, switching to run tab'); + setActiveTab('run'); + } + + // If this is an auth script and execution was successful, notify parent and refresh token if (result.success && fileName === 'get-access-token.js' && onTokenObtained) { onTokenObtained(); + + // Switch to Response tab to show authentication result + setActiveTab('run'); + + // Store complete config (credentials + server settings) if user checked the box + if (storeKeysLocally && allEnvVariables['CLIENT_ID'] && allEnvVariables['SECRET']) { + try { + const setName = credentialSetName.trim(); + if (!setName) { + console.warn('Cannot save credentials without a name during authentication'); + // Continue with authentication but don't save + setTimeout(() => checkAuthToken(), 1000); + return; + } + await fetch('/api/keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + clientId: allEnvVariables['CLIENT_ID'], + clientSecret: allEnvVariables['SECRET'], + baseURL: allEnvVariables['baseURL'], + authURL: allEnvVariables['authURL'], + name: setName, + setAsDefault: setAsDefault + }) + }); + setHasStoredKeys(true); + setCurrentFormIsStored(true); // Mark current form as stored + + // Show success notification for auto-save during authentication + showSaveNotification(`Config "${setName}" saved during authentication!`); + + // Update available sets + const updatedResponse = await fetch('/api/keys?list=true'); + if (updatedResponse.ok) { + const updatedData = await updatedResponse.json(); + setAvailableCredentialSets(updatedData.credentialSets || []); + setDefaultCredentialSet(updatedData.defaultSet || null); + } + } catch (error) { + console.error('Failed to store credentials:', error); + } + } + + // Refresh the auth token for smart parameter dropdowns + setTimeout(() => checkAuthToken(), 1000); } if (!response.ok) { @@ -205,11 +707,74 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = } }; + const loadCredentialSet = async (setName: string) => { + try { + const response = await fetch(`/api/keys?retrieve=true&set=${encodeURIComponent(setName)}`); + if (response.ok) { + const data = await response.json(); + if (data.credentials) { + // Load complete config: credentials + server settings + handleEnvChange('CLIENT_ID', data.credentials.clientId); + handleEnvChange('SECRET', data.credentials.clientSecret); + handleEnvChange('baseURL', data.credentials.baseURL); + handleEnvChange('authURL', data.credentials.authURL); + setCredentialSetName(setName); + setCurrentFormIsStored(true); // Mark current form as representing stored data + } + } + } catch (error) { + console.error('Failed to load credential set:', error); + } + }; + const handleEnvChange = (key: string, value: string) => { setEnvValues(prev => ({ ...prev, [key]: value })); + + // Mark form as unsaved when credentials or server settings change + if (['CLIENT_ID', 'SECRET', 'baseURL', 'authURL'].includes(key)) { + setCurrentFormIsStored(false); + } + }; + + const showSaveNotification = (message: string) => { + setSaveNotification(message); + setTimeout(() => setSaveNotification(null), 3000); // Auto-hide after 3 seconds + }; + + const deleteConfig = async (configName: string) => { + try { + await fetch(`/api/keys?config=${encodeURIComponent(configName)}`, { + method: 'DELETE' + }); + + // Clear form if we deleted the currently selected config + if (selectedCredentialSet === configName) { + setSelectedCredentialSet(''); + setCredentialSetName(''); + handleEnvChange('CLIENT_ID', ''); + handleEnvChange('SECRET', ''); + handleEnvChange('baseURL', 'https://aws-api.sigmacomputing.com/v2'); + handleEnvChange('authURL', 'https://aws-api.sigmacomputing.com/v2/auth/token'); + setCurrentFormIsStored(false); + } + + // Update available sets + const updatedResponse = await fetch('/api/keys?list=true'); + if (updatedResponse.ok) { + const updatedData = await updatedResponse.json(); + setAvailableCredentialSets(updatedData.credentialSets || []); + setDefaultCredentialSet(updatedData.defaultSet || null); + setHasStoredKeys(updatedData.credentialSets?.length > 0); + } + + showSaveNotification(`Config "${configName}" deleted successfully!`); + } catch (error) { + console.error('Failed to delete config:', error); + showSaveNotification('Failed to delete config. Please try again.'); + } }; if (!isOpen) return null; @@ -221,7 +786,6 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables =

    {fileName}

    -

    {filePath}

    ) : ( - // Regular recipe tab order: Parameters → Response → README → View Recipe (if params exist) + // Regular recipe tab order: Config → Response → README → View Recipe (if params exist) // Or: Response → README → View Recipe (if no params) <> - {detectedParameters.length > 0 && ( + {smartParameters.length > 0 && ( )} )} @@ -381,35 +945,215 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables =
    ) : (
    -

    Recipe Information

    -

    - This recipe demonstrates how to use the Sigma API for specific use cases. - Refer to the code and run the script to see the results. -

    + {readmeLoading ? ( +
    +
    +

    Loading README...

    +
    + ) : customReadme ? ( +
    +
    { + let html = customReadme; + + // Handle headers + html = html.replace(/^# (.+)$/gm, '

    $1

    '); + html = html.replace(/^## (.+)$/gm, '

    $1

    '); + html = html.replace(/^### (.+)$/gm, '

    $1

    '); + + // Handle inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Handle bold text + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + + // Handle links + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Process lists line by line + const lines = html.split('\n'); + const processed = []; + let inBulletList = false; + let inNumberList = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (trimmed.startsWith('- ')) { + if (!inBulletList) { + processed.push('
      '); + inBulletList = true; + } + if (inNumberList) { + processed.push(''); + inNumberList = false; + } + processed.push(`
    • ${trimmed.substring(2)}
    • `); + } else if (/^\d+\. /.test(trimmed)) { + if (!inNumberList) { + processed.push('
        '); + inNumberList = true; + } + if (inBulletList) { + processed.push('
    '); + inBulletList = false; + } + processed.push(`
  • ${trimmed.replace(/^\d+\. /, '')}
  • `); + } else { + if (inBulletList) { + processed.push(''); + inBulletList = false; + } + if (inNumberList) { + processed.push(''); + inNumberList = false; + } + if (trimmed === '') { + // Only add break if we're not between sections + const nextLine = lines[i + 1]?.trim(); + if (nextLine && !nextLine.startsWith('#')) { + processed.push('
    '); + } + } else if (trimmed.startsWith('#')) { + // Headers are already processed, just add the line + processed.push(line); + } else { + // Regular text - just add it with minimal spacing + processed.push(`
    ${line}
    `); + } + } + } + + // Close any open lists + if (inBulletList) processed.push(''); + if (inNumberList) processed.push(''); + + return processed.join('\n'); + })() + }} + /> + + ) : ( +
    +

    Recipe Information

    +

    + This recipe demonstrates how to use the Sigma API for specific use cases. + Refer to the code and run the script to see the results. +

    +
    + )} )} ) : activeTab === 'params' ? (
    {fileName === 'get-access-token.js' ? ( -
    -
    -

    šŸ” Authentication Credentials

    -

    - Enter your Sigma API credentials to authenticate -

    +
    + {/* Header with Setup Guide in top-right corner */} +
    +
    +

    šŸ” Authentication Request

    +

    + Configure your Sigma API credentials to access the platform +

    +

    + Once authenticated, use the "End Session" button in the header to clear your authentication +

    +
    + + šŸ“š Setup Guide +
    - -
    -