26 Commits

Author SHA1 Message Date
85a67867c9 Version 1.4.1 2026-02-02 07:14:25 +01:00
25d4668661 Updated version handing code in application. Small fixes. 2026-02-02 06:58:16 +01:00
62f7ec5a7c Removed unnecessary sample-data.json. 2026-02-02 06:45:40 +01:00
2d80a9dff1 Converted leftover CommonJS scripts to ESModule. 2026-02-02 06:43:40 +01:00
3f0a7d352d Updated .gitignore 2026-02-02 06:43:08 +01:00
3165432811 Added EditorConfig configuration file. 2026-02-02 06:41:38 +01:00
fd22751e72 Modernized NodeJS scripts. 2026-02-02 06:41:16 +01:00
2218581e78 Removed old AI generated upload.js 2026-02-02 06:34:55 +01:00
c21c0f863e Revised Docker image build process. 2026-02-02 06:11:15 +01:00
bcc7983849 Added reengineered version file creating module. 2026-02-02 05:53:35 +01:00
fbb98b7f39 refactor: streamline and clarify AI agent instructions in copilot documentation 2026-02-01 11:55:49 +01:00
d8bde75670 update: add extension to Bash script. 2026-02-01 11:55:43 +01:00
42e91f6ec1 fix: remove unnecessary whitespace in build-image.js 2026-01-31 23:50:55 +01:00
44bb4b7458 Switched from Bootstrap to Material UI. 2026-01-31 16:47:44 +01:00
794fd88e8d fix: small component size adjustments. 2026-01-31 16:43:38 +01:00
9f0d7ee70a Refined control sizes. 2026-01-31 15:57:37 +01:00
4c964cdfeb Resized JMESPath Expression area. 2026-01-31 15:48:27 +01:00
be6dc0de60 feat: Integrate Material UI theme provider and enhance UI components 2026-01-31 15:36:55 +01:00
dc9def4faf Refactor: Simplify response message in upload endpoint to "OK" 2026-01-31 15:36:47 +01:00
3dd352df92 Converted to Material UI v7 - bare. 2026-01-31 11:52:15 +01:00
57371feeb0 Release v1.3.1: Added PowerShell support and fixed theme issues 2026-01-31 11:06:25 +01:00
d398c34aa5 Migrate to Vite 7, improve UI (Copy/Download), and enhance API security 2026-01-31 10:16:49 +01:00
452e6e74cb update: moved input control buttons JSON Data area. 2026-01-31 09:10:55 +01:00
b7df3e731f Fix: the expression input box was getting reset while switching pages. Formatted the code text. 2026-01-31 09:05:07 +01:00
72d1be0bdc Enhance build-image script to support multi-architecture builds and improve help output 2026-01-30 00:06:11 +01:00
929d2ee5d2 Add upload scripts for PowerShell and NodeJS. 2026-01-30 00:04:02 +01:00
37 changed files with 4212 additions and 15835 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -1,109 +0,0 @@
---
description: Instructions for using the JMESPath Testing Tool repository.
applyTo: "**/*.md,**/.js"
---
# AI Agent Instructions for JMESPath Testing Tool
The tool in this repository is designed to help users validate and test JMESPath expressions against JSON data. It is a React-based web application that provides an interactive interface for entering JMESPath queries and viewing the results.
The main application page is divided into three sections:
- Top section: Title and description of the tool.
- Theme control buttons (auto/light/dark)
- Key-lock button that switches to the second application page.
- Middle section:
- The label "JMESPath Expression" with a right allinged row of action buttons:
- Load an Object
- Load a Log File
- Load Sample
- Format JSON
- Clear All
- Input area for JMESPath expressions
- Message area for errors related to JMESPath expression syntax
- Lower Middle left section: Input area for JSON data
- Lower Middle right section: Output are for JMESPath query results
- Bottom section: Footer with author and license information
The Middle section also contains a toolbar with buttons to load data from disk, load sample data, format JSON input, and clear all inputs.
The second page of the application contains:
- Top section: that is the same as the main page
- Middle section:
- API key display area with a button to regenerate the API key. The API key is 32 characters long cryptograghically secure random string.
- Instructions on how to use the API to upload sample data remotely with a code block displaying example curl command.
- Bottom section: Footer with author and license information.
The sample code block:
```bash
curl -s -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "X-API-Key: {{API_KEY}}" \
--data @{{JSON_FILE_NAME}} \
"{{API_URL}}/api/v1/upload"
```
Placeholders `{{API_KEY}}` and `{{API_URL}}` should be replaced with the actual API key and the URL of the deployed application respectively. The `{{JSON_FILE_NAME}}` placeholder should be shown as is to indicate the file containing the JSON data to be uploaded.
The server code is only used as a bridge between the UI app and the external tools that may upload the sample data. The server does not perform any JMESPath evaluation or JSON parsing; all such logic is handled in the React application.
The server exposes a REST API to allow external tools to upload sample data that users can load into the application. The API key is required to upload sample data.
The API key is used for:
- encrypting the sample data
- authenticating download requests
Session id is a hash of the API key.
The server keeps two pieces of information in memory for each session:
1. The sample data itself.
2. A state variable (a GUID) that changes whenever new sample data is uploaded.
The maximum number of sessions to keep in memory set at the server startup using `MAX_SESSIONS` environment variable that defaults to 100. The maximum size of the sample data is set using `MAX_SAMPLE_SIZE` environment variable that defaults to 1 MB. Maximum session age is controlled using `MAX_SESSION_TTL` environment variable that defaults to 1 hour. After reaching the maximum number of sessions, the server rejects new uploads until some sessions expire. Sessions older than the maximum session age are automatically purged.
The UI generates an API key at startup then load the sample data at startup and periodically checks the state variable to see if new sample data is available. If state variable changes, the React app displays a button beneath the expression input area to reload the sample data. The reload is performed only when the user clicks the button.
---
The main components of the application are located in the `src` directory and target Node 24 LTS environment.
Framework to be used:
- React for building the user interface.
- JavaScript (ES6+) for scripting.
- Bootstrap for styling and layout.
- Express.js for serving the application and handling API requests.
### API
The application exposes a REST API for remotly uploading sample data. The API endpoints are as follows:
- `POST /api/v1/upload`: The sample data is sent in the request body as JSON. The request must include an `x-api-key` header with the API key. If the upload is successful, the server responds with status 200 OK.
The server stores the sample data in memory and generates a new value for its state variable (a guid).
- `GET /api/v1/sample`: Returns the currently stored sample data as JSON. The request must include an `x-api-key` header with the API key. If the API key is invalid or the header is missing, the server responds with status 403 Forbidden.
- `GET /api/v1/state`: Returns the current value of the state variable (a guid) as a string. The request must include an `x-api-key` header with the API key. If the API key is invalid or the header is missing, the server responds with status 403 Forbidden.
## Containerization
The application should be prepared for deployment using containerization. It should extend minimal Node 24 LTS container image.
## Updates
Always use `scripts/new-version.js` script to make a new release.
Correct procedure to make a new release:
- Review the code changes and ensure everything is working.
- Run `npm run build` to build the React application.
- Run `npm test` to execute the test suite and ensure all tests pass.
- Prepare a commit message describing the changes made.
- Use `scripts/new-version.js` to create a new version and commit the changes. Use `--force` option if repository is not clean.
- Don't push the changes without approval.
- Don't build docker image without approval.

5
.gitignore vendored
View File

@@ -31,4 +31,7 @@ yarn-error.log*
# OS
.DS_Store
Thumbs.db
Thumbs.db
# Don't store AGENTS.md in git
AGENTS.md

View File

@@ -14,31 +14,16 @@ COPY package*.json ./
# Install dependencies (production + dev for build)
RUN npm ci
# Copy source code and build scripts
# Copy source code and build dependencies
COPY src/ ./src/
COPY public/ ./public/
COPY scripts/ ./scripts/
COPY server.js ./server.js
COPY vite.config.js ./vite.config.js
COPY index.html ./index.html
# Generate version.js if version info provided, otherwise run normal build
RUN if [ -n "$VERSION" ]; then \
echo "// Auto-generated version file - do not edit manually" > src/version.js && \
echo "// Generated at: $(date -Iseconds)" >> src/version.js && \
echo "" >> src/version.js && \
echo "export const VERSION = '$VERSION';" >> src/version.js && \
echo "export const IS_RELEASE = $IS_RELEASE;" >> src/version.js && \
echo "export const BUILD_TIME = '$(date -Iseconds)';" >> src/version.js && \
echo "📝 Generated version.js with VERSION=$VERSION, IS_RELEASE=$IS_RELEASE"; \
fi
# Build the application (skip prebuild if we already generated version.js)
RUN if [ -n "$VERSION" ]; then \
echo "🚀 Building with pre-generated version.js" && \
npx react-scripts build; \
else \
echo "🚀 Building with version-check.js" && \
npm run build; \
fi
# Build the application
RUN npm run build
# Production stage
FROM node:24-alpine AS production

View File

@@ -4,7 +4,7 @@ A React-based web application for testing and validating JMESPath expressions ag
![JMESPath Testing Tool](https://img.shields.io/badge/React-18.2.0-blue?style=flat-square&logo=react)
![Node.js](https://img.shields.io/badge/Node.js-24%20LTS-green?style=flat-square&logo=node.js)
![Bootstrap](https://img.shields.io/badge/Bootstrap-5.3.2-purple?style=flat-square&logo=bootstrap)
![Material UI](https://img.shields.io/badge/Material--UI-v7-007FFF?style=flat-square&logo=mui)
## Features
@@ -71,8 +71,8 @@ container run -p 3000:3000 jmespath-playground
1. **Enter a JMESPath expression** in the top input field (e.g., `people[*].name`)
2. **Add JSON data** using one of these methods:
- **Load an Object**: Click "📄 Load an Object" to upload standard JSON files (.json)
- **Load a Log File**: Click "📋 Load a Log File" to upload JSON Lines files (.log) - each line converted to array
- **Load an Object**: Click "Load an Object" to upload standard JSON files (.json)
- **Load a Log File**: Click "Load a Log File" to upload JSON Lines files (.log) - each line converted to array
- **Paste or type**: Enter JSON data directly in the bottom-left textarea
- **Load sample**: Use the "Load Sample" button for quick testing
3. **View the results** in the bottom-right output area
@@ -87,7 +87,7 @@ container run -p 3000:3000 jmespath-playground
The application includes a REST API for uploading sample data remotely:
1. **Access API Key**: Click the key-lock button (🔒) to view your unique API key
1. **Access API Key**: Click the key-lock button to view your unique API key
2. **Upload Data**: Use curl or any HTTP client to upload JSON data:
```bash
curl -X POST \
@@ -127,7 +127,7 @@ MAX_SESSIONS=200 MAX_SAMPLE_SIZE=2097152 LISTEN_PORT=8080 node server.js
## Technology Stack
- **React 18.2.0**: Frontend framework with modern hooks and components
- **Bootstrap 5.3.2**: CSS framework with dark/light theme support
- **Material UI v7**: Modern React component library following Material 3 Design principles.
- **JMESPath 0.16.0**: JMESPath expression evaluation library
- **Express.js 4.19.2**: Backend API server with session management
- **Node.js 24 LTS**: Runtime environment

72
bin/Upload-JMESPath.ps1 Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env pwsh
[CmdletBinding()]
param(
[Parameter(HelpMessage='Path to JSON file; default: read from stdin')]
[string]$JsonFile = '-',
[Parameter(HelpMessage='API base URL')]
[string]$ApiUrl,
[Parameter(HelpMessage='API key for authentication')]
[string]$ApiKey,
[Parameter(HelpMessage='Show help')]
[switch]$Help
)
function Show-Usage {
$usage = @"
Usage: Upload-JMESPath.ps1 [-ApiUrl <url>] [-ApiKey <key>] [-JsonFile <file>]
Parameters:
-ApiUrl <url> The base URL of the JMESPath Playground API (default: http://localhost:3000 or $env:JMESPATH_PLAYGROUND_API_URL)
-ApiKey <key> The API key for authentication (can also be set via JMESPATH_PLAYGROUND_API_KEY)
-JsonFile <file> The JSON file to upload (default: stdin if not specified)
-Help Show this help message and exit
"@
Write-Output $usage
}
if ($Help) { Show-Usage; exit 0 }
# Apply environment defaults when parameters are not provided
if (-not $ApiUrl) {
if ($env:JMESPATH_PLAYGROUND_API_URL) { $ApiUrl = $env:JMESPATH_PLAYGROUND_API_URL } else { $ApiUrl = 'http://localhost:3000' }
}
if (-not $ApiKey) {
$ApiKey = $env:JMESPATH_PLAYGROUND_API_KEY
}
# Read JSON body from file or stdin
try {
if ($JsonFile -eq '-' -or [string]::IsNullOrEmpty($JsonFile)) {
$Body = [Console]::In.ReadToEnd()
} else {
if (-not (Test-Path -Path $JsonFile)) {
Write-Error "JSON file not found: $JsonFile"
exit 2
}
$Body = Get-Content -Raw -Path $JsonFile
}
} catch {
Write-Error "Failed to read JSON input: $($_.Exception.Message)"
exit 2
}
# Prepare headers
$Headers = @{ 'Accept' = 'application/json' }
if (-not [string]::IsNullOrEmpty($ApiKey)) { $Headers['X-API-Key'] = $ApiKey }
# POST to API
$Uri = "$ApiUrl/api/v1/upload"
try {
$response = Invoke-RestMethod -Uri $Uri -Method Post -Headers $Headers -ContentType 'application/json' -Body $Body -ErrorAction Stop
if ($null -ne $response) {
$response | ConvertTo-Json -Depth 10
} else {
Write-Output "Upload completed."
}
} catch {
Write-Error "Upload failed: $($_.Exception.Message)"
exit 3
}

94
bin/upload-jmespath.mjs Executable file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
// Parse command-line arguments
import { parseArgs } from "util";
const args = parseArgs({
options: {
"api-url": {
type: "string",
short: "a",
default: process.env.JMESPATH_PLAYGROUND_API_URL || "http://localhost:3000",
},
"api-key": {
type: "string",
short: "k",
default: process.env.JMESPATH_PLAYGROUND_API_KEY || "",
},
"json-file": {
type: "string",
short: "j",
default: "",
},
help: { type: "boolean", short: "h" },
},
});
// Show help message
if (args.values.help) {
console.log(`
Usage: upload-jmespath.mjs [options]
Options:
-a, --api-url API base URL (default: http://localhost:3000)
-k, --api-key API key for authentication
-j, --json-file Path to the JSON file to upload
-h, --help Show this help message
`);
process.exit(0);
}
// Read the JSON from the specfied file or from stdin if no file is provided
async function readJson(filePath) {
if (filePath) {
const absolutePath = path.resolve(filePath);
const fileContent = fs.readFileSync(absolutePath, "utf-8");
return JSON.parse(fileContent);
} else {
return new Promise((resolve, reject) => {
let data = "";
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => {
try {
resolve(JSON.parse(data));
} catch (error) {
reject(error);
}
});
});
}
}
// Upload the JSON data to the API using built-in fetch
async function uploadJson(apiUrl, apiKey, jsonData) {
const response = await fetch(`${apiUrl}/api/v1/upload`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { "X-API-Key": `${apiKey}` } : {}),
},
body: JSON.stringify(jsonData),
});
if (!response.ok) {
throw new Error(`Failed to upload JSON: ${response.status} ${response.statusText}`);
}
}
// Main function
async function main() {
try {
const jsonData = await readJson(args.values["json-file"]);
await uploadJson(args.values["api-url"], args.values["api-key"], jsonData);
console.log("JSON uploaded successfully.");
} catch (error) {
console.error("Error:", error.message);
process.exit(1);
}
}
main();

View File

@@ -2,12 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="JMESPath Testing Tool - Validate and test JMESPath expressions against JSON data" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700&family=Noto+Sans+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
@@ -16,5 +15,6 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
</html>

16286
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,36 @@
{
"name": "jmespath-playground",
"version": "1.2.5",
"version": "1.4.1",
"description": "A React-based web application for testing JMESPath expressions against JSON data",
"main": "index.js",
"scripts": {
"start": "react-scripts start",
"prebuild": "node scripts/version-check.js",
"build": "react-scripts build",
"test": "react-scripts test --watchAll=false",
"test:watch": "react-scripts test",
"server": "node server.js",
"dev": "concurrently \"npm start\" \"npm run server\"",
"build-image": "node scripts/build-image.js"
"start": "vite",
"prebuild": "node scripts/version.mjs",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"server": "node server.js --dev",
"dev": "concurrently \"npm start\" \"node --watch server.js --dev\"",
"build-image": "vite build && node scripts/build-image.mjs"
},
"proxy": "http://localhost:3000",
"engines": {
"node": ">=24.0.0"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.1",
"bootstrap": "^5.3.2",
"express": "^4.19.2",
"jmespath": "^0.16.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "^5.0.1",
"semver": "^7.7.3",
"uuid": "^9.0.0"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/index.js"
]
},
"browserslist": {
"production": [
">0.2%",
@@ -63,7 +53,12 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.18",
"concurrently": "^8.2.2",
"supertest": "^7.2.2"
"jsdom": "^27.4.0",
"supertest": "^7.2.2",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

View File

@@ -1,89 +0,0 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
function execCommand(command, description) {
try {
console.log(`${description}...`);
execSync(command, { stdio: 'inherit' });
} catch (error) {
console.error(`Error: ${description} failed`);
process.exit(1);
}
}
function getContainerTool() {
// Check for Docker first (primary tool)
try {
execSync('docker --version', { stdio: 'ignore' });
return 'docker';
} catch (error) {
// Fall back to Apple's container command
try {
execSync('container --version', { stdio: 'ignore' });
return 'container';
} catch (error) {
console.error('Error: No container tool found. Please install Docker or Apple Container Tools to build container images.');
process.exit(1);
}
}
}
function getVersion() {
try {
// Try to get version from git tag
const gitTag = execSync('git tag --points-at HEAD', { encoding: 'utf8' }).trim();
if (gitTag) {
return { version: gitTag.replace(/^v/, ''), isRelease: true };
}
} catch (error) {
// Git command failed, ignore
}
// Development build - use package.json version with -dev suffix
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
return { version: `${packageJson.version}-dev`, isRelease: false };
}
function main() {
const containerTool = getContainerTool();
const { version, isRelease } = getVersion();
console.log(`Building ${isRelease ? 'release' : 'development'} version: ${version}`);
// Build container image
const tags = isRelease
? [
`-t skoszewski/jmespath-playground:${version}`,
`-t skoszewski/jmespath-playground:latest`
].join(' ')
: [
`-t skoszewski/jmespath-playground:dev`,
`-t skoszewski/jmespath-playground:latest`
].join(' ');
const buildCommand = `${containerTool} build --build-arg VERSION="${version}" --build-arg IS_RELEASE="${isRelease}" ${tags} .`;
execCommand(buildCommand, 'Building container image');
console.log('Container image build completed successfully!');
// Show usage instructions
if (isRelease) {
console.log(`\nTo run the container:`);
console.log(` ${containerTool} run --name jmespathpg -p 3000:3000 skoszewski/jmespath-playground:${version}`);
if (containerTool === 'docker') {
console.log(`\nTo push to Docker Hub:`);
console.log(` docker push skoszewski/jmespath-playground:${version}`);
console.log(` docker push skoszewski/jmespath-playground:latest`);
}
} else {
console.log(`\nTo run the container:`);
console.log(` ${containerTool} run --name jmespathpg -p 3000:3000 skoszewski/jmespath-playground:dev`);
}
}
if (require.main === module) {
main();
}

183
scripts/build-image.mjs Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function execCommand(command, description) {
try {
console.log(`${description}...`);
execSync(command, { stdio: 'inherit' });
} catch (error) {
console.error(`Error: ${description} failed`);
process.exit(1);
}
}
function getContainerTool() {
// Check for Docker first (primary tool)
try {
execSync('docker --version', { stdio: 'ignore' });
return 'docker';
} catch (error) {
// Fall back to Apple's container command
try {
execSync('container --version', { stdio: 'ignore' });
return 'container';
} catch (error) {
console.error('Error: No container tool found. Please install Docker or Apple Container Tools to build container images.');
process.exit(1);
}
}
}
async function generateVersionFile() {
const versionModuleUrl = pathToFileURL(path.join(__dirname, 'version.mjs')).href;
const { generateVersionFile: generate } = await import(versionModuleUrl);
const versionFilePath = path.join(__dirname, '..', 'src', 'version.js');
generate(versionFilePath);
return versionFilePath;
}
function readVersionFile(versionFilePath) {
const contents = fs.readFileSync(versionFilePath, 'utf8');
const versionMatch = contents.match(/export const VERSION = '([^']+)';/);
const releaseMatch = contents.match(/export const IS_RELEASE = (true|false);/);
if (!versionMatch || !releaseMatch) {
throw new Error(`Could not parse version file at ${versionFilePath}`);
}
return {
version: versionMatch[1],
isRelease: releaseMatch[1] === 'true'
};
}
function getHostArchitecture() {
// Map Node.js architecture names to container architecture names
const archMap = {
'arm64': 'arm64',
'arm': 'arm64',
'x64': 'amd64'
};
return archMap[process.arch] || 'arm64';
}
function showHelp() {
const hostArch = getHostArchitecture();
console.log(`Build multi-architecture container images for JMESPath Playground
Usage:
build-image.mjs [OPTIONS]
Options:
--all-arch Build for both arm64 and amd64 (default: build for host architecture only)
--arch <arch> Target architecture (arm64 or amd64). Can be specified multiple times.
--help, -h Show this help message and exit
Examples:
build-image.mjs # Builds for ${hostArch} only (host architecture)
build-image.mjs --all-arch # Builds for both arm64 and amd64
build-image.mjs --arch arm64 # Builds for arm64 only
build-image.mjs --arch arm64 --arch amd64 # Explicitly specify both
build-image.mjs -h # Show help`);
}
async function main() {
const { values } = parseArgs({
options: {
help: {
type: 'boolean',
short: 'h',
description: 'Show help'
},
'all-arch': {
type: 'boolean',
description: 'Build for both arm64 and amd64'
},
arch: {
type: 'string',
multiple: true,
description: 'Target architecture (arm64 or amd64)'
}
},
strict: true,
allowPositionals: false
});
if (values.help) {
showHelp();
process.exit(0);
}
const containerTool = getContainerTool();
const versionFilePath = await generateVersionFile();
const { version, isRelease } = readVersionFile(versionFilePath);
let architectures;
if (values['all-arch']) {
architectures = ['arm64', 'amd64'];
} else if (values.arch && values.arch.length > 0) {
architectures = values.arch;
} else {
architectures = [getHostArchitecture()];
}
console.log(`Building ${isRelease ? 'release' : 'development'} version: ${version}`);
console.log(`Target architectures: ${architectures.join(', ')}`);
// Build container image
const tags = isRelease
? [
`-t skoszewski/jmespath-playground:${version}`,
`-t skoszewski/jmespath-playground:latest`
].join(' ')
: [
`-t skoszewski/jmespath-playground:dev`,
`-t skoszewski/jmespath-playground:latest`
].join(' ');
const archFlags = architectures.map(arch => `--arch ${arch}`).join(' ');
const buildCommand = `${containerTool} build ${archFlags} --build-arg VERSION="${version}" --build-arg IS_RELEASE="${isRelease}" ${tags} .`;
execCommand(buildCommand, 'Building container image');
console.log('Container image build completed successfully!');
// Show usage instructions
console.log(`\nUsage examples:`);
console.log(` build-image.mjs # Builds for host architecture only`);
console.log(` build-image.mjs --all-arch # Builds for both arm64 and amd64`);
console.log(` build-image.mjs --arch arm64 # Builds for arm64 only`);
console.log(` build-image.mjs --arch arm64 --arch amd64 # Explicitly specify both`);
if (isRelease) {
console.log(`\nTo run the container:`);
console.log(` ${containerTool} run --arch arm64 --name jmespathpg -p 3000:3000 skoszewski/jmespath-playground:${version}`);
if (containerTool === 'docker') {
console.log(`\nTo push to Docker Hub:`);
console.log(` docker push skoszewski/jmespath-playground:${version}`);
console.log(` docker push skoszewski/jmespath-playground:latest`);
}
} else {
console.log(`\nTo run the container:`);
console.log(` ${containerTool} run --arch arm64 --name jmespathpg -p 3000:3000 skoszewski/jmespath-playground:dev`);
}
}
const isDirectRun = process.argv[1]
&& fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isDirectRun) {
main().catch((error) => {
console.error(`Error: ${error.message}`);
process.exit(1);
});
}

View File

@@ -1,13 +1,46 @@
#!/usr/bin/env node
const fs = require('fs');
const { execSync } = require('child_process');
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import semver from 'semver';
function tagMatchesVersion(tag, version) {
if (!tag) {
return false;
}
if (tag === version) {
return true;
}
if (tag.startsWith('v')) {
return tag.slice(1) === version;
}
return false;
}
function hasMatchingTag(tagsOutput, version) {
return tagsOutput
.split('\n')
.map(tag => tag.trim())
.filter(Boolean)
.some(tag => tagMatchesVersion(tag, version));
}
function findMatchingTag(tagsOutput, version) {
return tagsOutput
.split('\n')
.map(tag => tag.trim())
.filter(Boolean)
.find(tag => tagMatchesVersion(tag, version)) || null;
}
function showUsage() {
console.log('Usage: node scripts/new-version.js <version> [--force] [-m|--message "commit message"]');
console.log(' node scripts/new-version.js --check <version>');
console.log('Usage: node scripts/new-version.mjs <version> [--force] [-m|--message "commit message"]');
console.log(' node scripts/new-version.mjs --check <version>');
console.log('');
console.log('Creates a new version by tagging the current commit.');
console.log('Version must be valid semver (e.g., 1.2.3).');
console.log('');
console.log('Options:');
console.log(' --force Force version creation even with dirty repo or package.json mismatch');
@@ -15,14 +48,14 @@ function showUsage() {
console.log(' -m, --message TEXT Custom commit message (only used when commit is needed)');
console.log('');
console.log('Example:');
console.log(' node scripts/new-version.js 1.2.0');
console.log(' node scripts/new-version.js 1.2.0 --force');
console.log(' node scripts/new-version.js 1.2.0 -m "Add new feature XYZ"');
console.log(' node scripts/new-version.js --check 1.3.0');
console.log(' node scripts/new-version.mjs 1.2.0');
console.log(' node scripts/new-version.mjs 1.2.0 --force');
console.log(' node scripts/new-version.mjs 1.2.0 -m "Add new feature XYZ"');
console.log(' node scripts/new-version.mjs --check 1.3.0');
}
function performCheck(targetVersion) {
console.log('🔍 Repository Analysis Report');
console.log('Repository Analysis Report');
console.log('============================');
try {
@@ -31,7 +64,7 @@ function performCheck(targetVersion) {
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const currentVersion = pkg.version;
console.log(`📦 Package.json version: ${currentVersion}`);
console.log(`Package.json version: ${currentVersion}`);
// Check repository status
let isRepoDirty = false;
@@ -41,71 +74,71 @@ function performCheck(targetVersion) {
isRepoDirty = status.trim() !== '';
dirtyFiles = status.trim();
} catch (error) {
console.log('⚠️ Cannot determine git status');
console.log('Warning: Cannot determine git status');
}
if (isRepoDirty) {
console.log('🔄 Repository status: DIRTY');
console.log('Repository status: DIRTY');
console.log(' Uncommitted changes:');
dirtyFiles.split('\n').forEach(line => {
if (line.trim()) console.log(` ${line}`);
});
} else {
console.log('Repository status: CLEAN');
console.log('Repository status: CLEAN');
}
// Check current commit info
try {
const currentCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
console.log(`🌟 Current commit: ${currentCommit.substring(0, 7)} (${currentBranch})`);
console.log(`Current commit: ${currentCommit.substring(0, 7)} (${currentBranch})`);
// Check if current commit is tagged
const tagsOnHead = execSync('git tag --points-at HEAD', { encoding: 'utf8' }).trim();
if (tagsOnHead) {
console.log(`🏷️ Current commit tags: ${tagsOnHead.split('\n').join(', ')}`);
console.log(`Current commit tags: ${tagsOnHead.split('\n').join(', ')}`);
} else {
console.log('🏷️ Current commit: No tags');
console.log('Current commit: No tags');
}
} catch (error) {
console.log('⚠️ Cannot determine commit info');
console.log('Warning: Cannot determine commit info');
}
// List recent tags
try {
const recentTags = execSync('git tag --sort=-version:refname | head -5', { encoding: 'utf8' }).trim();
if (recentTags) {
console.log('📋 Recent tags:');
console.log('Recent tags:');
recentTags.split('\n').forEach(tag => {
if (tag.trim()) console.log(` ${tag}`);
});
} else {
console.log('📋 No tags found in repository');
console.log('No tags found in repository');
}
} catch (error) {
console.log('⚠️ Cannot list tags');
console.log('Warning: Cannot list tags');
}
console.log('');
// Analysis for target version (if provided)
if (targetVersion) {
const tagName = `v${targetVersion}`;
console.log(`🎯 Analysis for version ${targetVersion}:`);
const tagName = targetVersion;
console.log(`Analysis for version ${targetVersion}:`);
console.log('=====================================');
// Check if target tag exists
try {
const existingTags = execSync('git tag -l', { encoding: 'utf8' });
const tagExists = existingTags.split('\n').includes(tagName);
const matchingTag = findMatchingTag(existingTags, targetVersion);
if (tagExists) {
console.log(` Tag '${tagName}' already exists - CANNOT CREATE`);
if (matchingTag) {
console.log(`Error: Tag '${matchingTag}' already exists - CANNOT CREATE`);
return;
}
console.log(`Tag '${tagName}' available`);
console.log(`Tag '${tagName}' available`);
} catch (error) {
console.log('⚠️ Cannot check tag availability');
console.log('Warning: Cannot check tag availability');
return;
}
@@ -114,46 +147,46 @@ function performCheck(targetVersion) {
const needsPackageUpdate = !packageJsonMatches;
const needsCommit = isRepoDirty || needsPackageUpdate;
console.log(`📝 Package.json: ${packageJsonMatches ? 'MATCHES' : `NEEDS UPDATE (${currentVersion} ${targetVersion})`}`);
console.log(`Package.json: ${packageJsonMatches ? 'MATCHES' : `NEEDS UPDATE (${currentVersion} -> ${targetVersion})`}`);
if (needsCommit) {
console.log('Actions needed:');
console.log('Actions needed:');
if (needsPackageUpdate) {
console.log(' Update package.json');
console.log(' - Update package.json');
}
if (isRepoDirty) {
console.log(' Stage uncommitted changes');
console.log(' - Stage uncommitted changes');
}
console.log(' Create commit');
console.log(` Create tag ${tagName}`);
console.log(' - Create commit');
console.log(` - Create tag ${tagName}`);
console.log('');
console.log('📋 Commands that would work:');
console.log('Commands that would work:');
if (isRepoDirty || needsPackageUpdate) {
console.log(` node scripts/new-version.js ${targetVersion} --force`);
console.log(` node scripts/new-version.mjs ${targetVersion} --force`);
} else {
console.log(` node scripts/new-version.js ${targetVersion}`);
console.log(` node scripts/new-version.js ${targetVersion} --force`);
console.log(` node scripts/new-version.mjs ${targetVersion}`);
console.log(` node scripts/new-version.mjs ${targetVersion} --force`);
}
} else {
console.log('Actions needed:');
console.log(` Create tag ${tagName} (no commit needed)`);
console.log('Actions needed:');
console.log(` - Create tag ${tagName} (no commit needed)`);
console.log('');
console.log('📋 Commands that would work:');
console.log(` node scripts/new-version.js ${targetVersion}`);
console.log(` node scripts/new-version.js ${targetVersion} --force`);
console.log('Commands that would work:');
console.log(` node scripts/new-version.mjs ${targetVersion}`);
console.log(` node scripts/new-version.mjs ${targetVersion} --force`);
}
console.log('');
console.log('🚦 Default mode requirements:');
console.log('Default mode requirements:');
if (isRepoDirty) {
console.log(' Repository must be clean (currently dirty)');
console.log(' Repository must be clean (currently dirty)');
} else {
console.log(' Repository is clean');
console.log(' Repository is clean');
}
if (!packageJsonMatches) {
console.log(` Package.json must match version (currently ${currentVersion})`);
console.log(` Package.json must match version (currently ${currentVersion})`);
} else {
console.log(' Package.json version matches');
console.log(' Package.json version matches');
}
} else {
@@ -163,7 +196,7 @@ function performCheck(targetVersion) {
}
} catch (error) {
console.error('Error during analysis:', error.message);
console.error('Error during analysis:', error.message);
process.exit(1);
}
}
@@ -200,28 +233,43 @@ function main() {
// For normal operation, version is required
newVersion = args.find(arg => !arg.startsWith('--') && arg !== '-m' && arg !== customMessage);
if (!newVersion) {
console.error('Error: Version argument required');
showUsage();
process.exit(1);
}
}
if (newVersion && newVersion.startsWith('v')) {
console.error('Error: Version must not start with "v". Use plain semver like 1.2.3.');
process.exit(1);
}
const normalizedVersion = newVersion;
if (!semver.valid(normalizedVersion)) {
console.error('Error: Version must be valid semver (e.g., 1.2.3)');
process.exit(1);
}
if (isCheck) {
performCheck(newVersion);
performCheck(normalizedVersion);
return;
}
const tagName = `v${newVersion}`;
const tagName = normalizedVersion;
console.log(`🏷️ Creating new version: ${newVersion}${isForce ? ' (forced)' : ''}`);
console.log(`Creating new version: ${normalizedVersion}${isForce ? ' (forced)' : ''}`);
try {
// 1. Check if tag already exists - Always ERROR
try {
const existingTags = execSync('git tag -l', { encoding: 'utf8' });
if (existingTags.split('\n').includes(tagName)) {
console.error(`❌ Error: Tag '${tagName}' already exists`);
const matchingTag = findMatchingTag(existingTags, normalizedVersion);
if (matchingTag) {
console.error(`Error: Tag '${matchingTag}' already exists`);
process.exit(1);
}
} catch (error) {
console.error('Error: Failed to check existing tags');
console.error('Error: Failed to check existing tags');
process.exit(1);
}
@@ -231,7 +279,7 @@ function main() {
const status = execSync('git status --porcelain', { encoding: 'utf8' });
isRepoDirty = status.trim() !== '';
} catch (error) {
console.error('Error: Failed to check git status');
console.error('Error: Failed to check git status');
process.exit(1);
}
@@ -239,7 +287,7 @@ function main() {
const packagePath = './package.json';
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const currentVersion = pkg.version;
const packageJsonMatches = currentVersion === newVersion;
const packageJsonMatches = currentVersion === normalizedVersion;
// 4. Determine what action is needed
const needsPackageUpdate = !packageJsonMatches;
@@ -248,12 +296,12 @@ function main() {
// 5. Check if force is required
if (!isForce) {
if (isRepoDirty) {
console.error('Error: Working directory has uncommitted changes');
console.error('Error: Working directory has uncommitted changes');
console.error('Please commit your changes first or use --force');
process.exit(1);
}
if (needsPackageUpdate) {
console.error(`Error: Package.json version is ${currentVersion}, requested ${newVersion}`);
console.error(`Error: Package.json version is ${currentVersion}, requested ${normalizedVersion}`);
console.error('Use --force to update package.json');
process.exit(1);
}
@@ -261,40 +309,45 @@ function main() {
// 6. Execute the versioning
if (needsCommit) {
console.log(`📦 Needs commit: ${needsPackageUpdate ? 'package.json update' : ''}${needsPackageUpdate && isRepoDirty ? ' + ' : ''}${isRepoDirty ? 'uncommitted changes' : ''}`);
console.log(`Needs commit: ${needsPackageUpdate ? 'package.json update' : ''}${needsPackageUpdate && isRepoDirty ? ' + ' : ''}${isRepoDirty ? 'uncommitted changes' : ''}`);
// Update package.json if needed
if (needsPackageUpdate) {
pkg.version = newVersion;
pkg.version = normalizedVersion;
fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`📝 Updated package.json: ${currentVersion} ${newVersion}`);
console.log(`Updated package.json: ${currentVersion} -> ${normalizedVersion}`);
}
// Stage all changes
execSync('git add .', { stdio: 'inherit' });
// Commit
const commitMessage = customMessage || (needsPackageUpdate ? `Version ${newVersion}` : `Prepare for version ${newVersion}`);
const commitMessage = customMessage || (needsPackageUpdate ? `Version ${normalizedVersion}` : `Prepare for version ${normalizedVersion}`);
execSync(`git commit -m "${commitMessage}"`, { stdio: 'inherit' });
console.log(`Committed changes`);
console.log('Committed changes');
} else {
console.log(`Repository clean, package.json matches - tagging current commit`);
console.log('Repository clean, package.json matches - tagging current commit');
}
// 7. Tag the commit
execSync(`git tag ${tagName}`, { stdio: 'inherit' });
console.log(`🏷️ Created tag: ${tagName}`);
console.log(`Created tag: ${tagName}`);
console.log('');
console.log('🎉 Version created successfully!');
console.log('Version created successfully!');
console.log('');
console.log('Next steps:');
console.log(` git push origin main --tags # Push the commit and tag`);
} catch (error) {
console.error('Error during version creation:', error.message);
console.error('Error during version creation:', error.message);
process.exit(1);
}
}
main();
const isDirectRun = process.argv[1]
&& fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isDirectRun) {
main();
}

View File

@@ -1,30 +0,0 @@
{
"users": [
{
"id": 1,
"name": "Alice Johnson",
"email": "alice@example.com",
"role": "admin",
"skills": ["JavaScript", "Python", "SQL"]
},
{
"id": 2,
"name": "Bob Wilson",
"email": "bob@example.com",
"role": "developer",
"skills": ["Java", "Spring", "React"]
},
{
"id": 3,
"name": "Carol Davis",
"email": "carol@example.com",
"role": "designer",
"skills": ["Figma", "Photoshop", "CSS"]
}
],
"metadata": {
"total": 3,
"created": "2026-01-21",
"version": "1.0"
}
}

View File

@@ -1,172 +0,0 @@
#!/usr/bin/env node
/**
* JMESPath Playground Upload Script (JavaScript)
* Usage: node upload.js [-u URL] [-k API_KEY] "json_file.json"
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { URL } = require('url');
const { parseArgs } = require('util');
function showUsage() {
const scriptName = path.basename(process.argv[1]);
console.log(`Usage: node ${scriptName} [-u|--url URL] [-k|--key API_KEY] <json_file>`);
console.log('');
console.log('Options:');
console.log(' -u, --url URL API URL (default: http://localhost:3000)');
console.log(' -k, --key API_KEY API key (not required for localhost)');
console.log(' -h, --help Show this help message');
console.log('');
console.log('Examples:');
console.log(` node ${scriptName} data.json`);
console.log(` node ${scriptName} -u http://example.com:3000 -k your-api-key data.json`);
}
function getArguments() {
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
url: { type: 'string', short: 'u', default: 'http://localhost:3000' },
key: { type: 'string', short: 'k' },
help: { type: 'boolean', short: 'h' }
},
allowPositionals: true
});
if (values.help) {
showUsage();
process.exit(0);
}
if (positionals.length !== 1) {
console.error('Error: JSON file required');
showUsage();
process.exit(1);
}
return {
apiUrl: values.url,
apiKey: values.key || '',
jsonFile: positionals[0]
};
}
async function validateJsonFile(jsonFile) {
// Check if file exists
if (!fs.existsSync(jsonFile)) {
console.error(`Error: JSON file '${jsonFile}' not found`);
process.exit(1);
}
// Validate JSON content
try {
const content = fs.readFileSync(jsonFile, 'utf8');
JSON.parse(content);
return content;
} catch (error) {
console.error(`Error: '${jsonFile}' contains invalid JSON`);
console.error(error.message);
process.exit(1);
}
}
function isLocalhost(url) {
try {
const parsed = new URL(url);
const hostname = parsed.hostname;
return hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname.startsWith('127.') ||
hostname === '::1';
} catch {
return false;
}
}
function makeRequest(url, options) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const isHttps = parsedUrl.protocol === 'https:';
const client = isHttps ? https : http;
const requestOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname,
method: options.method || 'GET',
headers: options.headers || {}
};
const req = client.request(requestOptions, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
json: () => Promise.resolve(JSON.parse(data))
});
});
});
req.on('error', reject);
if (options.body) {
req.write(options.body);
}
req.end();
});
}
async function uploadData(apiUrl, apiKey, jsonFile, jsonData) {
try {
const headers = {
'Content-Type': 'application/json',
};
// Only send API key for non-localhost requests
const isLocal = isLocalhost(apiUrl);
if (!isLocal && apiKey) {
headers['X-API-Key'] = apiKey;
} else if (!isLocal && !apiKey) {
console.error('Error: API key required for non-localhost URLs');
console.error('Use -k/--key option to specify API key');
process.exit(1);
}
const response = await makeRequest(`${apiUrl}/api/v1/upload`, {
method: 'POST',
headers: headers,
body: jsonData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${errorData.error || 'Upload failed'}`);
}
const result = await response.json();
console.log(JSON.stringify(result));
} catch (error) {
console.error('Error uploading data:', error.message);
process.exit(1);
}
}
async function main() {
const { apiUrl, apiKey, jsonFile } = getArguments();
const jsonData = await validateJsonFile(jsonFile);
await uploadData(apiUrl, apiKey, jsonFile, jsonData);
}
// Run the script
main().catch((error) => {
console.error('Unexpected error:', error);
process.exit(1);
});

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Read package.json for base version
const packagePath = './package.json';
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
let version = pkg.version;
let isRelease = false;
try {
// Check if current commit is tagged
const gitTag = execSync('git tag --points-at HEAD', { encoding: 'utf8' }).trim();
if (gitTag) {
// We're at a tagged commit - extract version from tag
const tagVersion = gitTag.replace(/^v/, ''); // Remove 'v' prefix if present
version = tagVersion;
console.log(`✅ Building release version ${version} (tagged: ${gitTag})`);
isRelease = true;
} else {
// We're not at a tagged commit - use unknown version
version = 'unknown';
console.log(`📦 Building development version with unknown version`);
isRelease = false;
}
} catch (error) {
// Git command failed (maybe not in a git repo)
version = 'unknown';
console.log(`⚠️ Cannot determine git status, using unknown version`);
isRelease = false;
}
// Generate version.js file
const versionFile = path.join('./src', 'version.js');
const versionContent = `// Auto-generated version file - do not edit manually
// Generated at: ${new Date().toISOString()}
export const VERSION = '${version}';
export const IS_RELEASE = ${isRelease};
export const BUILD_TIME = '${new Date().toISOString()}';
`;
fs.writeFileSync(versionFile, versionContent);
console.log(`📝 Generated ${versionFile} with version ${version}`);

69
scripts/version.mjs Normal file
View File

@@ -0,0 +1,69 @@
import { readFileSync, write, writeFileSync } from "fs";
import { execSync } from "child_process";
import semver from "semver";
export function getGitVersion() {
let rawGitVersion;
let gitVersion;
try {
rawGitVersion = execSync("git describe --tags --dirty").toString().trim();
gitVersion = semver.coerce(rawGitVersion) || semver.coerce("0.0.0");
} catch (e) {
return "0.0.0";
}
// Git describe may return versions like v1.2.3-4-gabcdef
// or v1.2.3-dirty or v1.2.3 or v1.2.3-4-gabcdef-dirty.
// We need to return either a clean version or
// append -dev for modified versions and
// -dirty for dirty working tree.
if (rawGitVersion.endsWith("-dirty")) {
return gitVersion.version + "-dirty";
} else if (rawGitVersion.includes("-")) {
return gitVersion.version + "-dev";
} else {
return gitVersion.version || "0.0.0";
}
}
export function generateVersionFile(versionFilePath) {
// Read package.json version
const packageVersion = JSON.parse(
readFileSync("package.json", { encoding: "utf-8" }),
).version;
// Get version from git repository
const gitVersion = getGitVersion();
const gitBaseVersion = semver.coerce(gitVersion)?.version;
// if git returned malformed version, throw error
if (!gitBaseVersion || gitBaseVersion === "0.0.0") {
throw new Error(
"Cannot determine git version. Make sure the script is run in a git repository with tags.",
);
}
// Compare git version with package.json version
if (semver.neq(gitBaseVersion, packageVersion)) {
throw new Error(
`Version mismatch: package.json version is ${packageVersion}, but git version is ${gitBaseVersion}`,
);
}
// Generate version file
const buildDate = new Date().toISOString();
writeFileSync(
versionFilePath,
`// Auto-generated version file - do not edit manually
// Generated at: ${buildDate}
export const VERSION = '${packageVersion}';
export const IS_RELEASE = ${gitVersion === packageVersion};
export const BUILD_TIME = '${buildDate}';
`,
);
}
if (import.meta.url === `file://${process.argv[1]}`) {
generateVersionFile("src/version.js");
}

344
server.js
View File

@@ -1,9 +1,9 @@
const express = require('express');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { v4: uuidv4 } = require('uuid');
const { parseArgs } = require('util');
const express = require("express");
const path = require("path");
const crypto = require("crypto");
const os = require("os");
const { v4: uuidv4 } = require("uuid");
const { parseArgs } = require("util");
// Environment configuration
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS) || 100;
@@ -11,54 +11,29 @@ const MAX_SAMPLE_SIZE = parseInt(process.env.MAX_SAMPLE_SIZE) || 1024 * 1024; //
const MAX_SESSION_TTL = parseInt(process.env.MAX_SESSION_TTL) || 60 * 60 * 1000; // 1 hour
// Utility functions for encryption
function generateSalt() {
return crypto.randomBytes(16);
}
function isLocalhostRequest(req) {
// Get client IP with fallback options
const forwarded = req.get('X-Forwarded-For');
const ip = forwarded ? forwarded.split(',')[0].trim() :
req.ip ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
'127.0.0.1';
const host = req.get('host') || '';
// Check for localhost IP addresses (IPv4 and IPv6)
const localhostIPs = ['127.0.0.1', '::1', '::ffff:127.0.0.1', 'localhost'];
const isLocalIP = localhostIPs.includes(ip) || ip.startsWith('127.') || ip === '::1';
// Check for localhost hostnames
const isLocalHost = host.startsWith('localhost:') || host.startsWith('127.0.0.1:') || host === 'localhost' || host === '127.0.0.1';
return isLocalIP || isLocalHost;
}
function encrypt(data, key) {
try {
const algorithm = 'aes-256-gcm';
const algorithm = "aes-256-gcm";
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
cipher.setAAD(Buffer.from('session-data'));
cipher.setAAD(Buffer.from("session-data"));
let encrypted = cipher.update(JSON.stringify(data), 'utf8');
let encrypted = cipher.update(JSON.stringify(data), "utf8");
encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag();
return {
iv: iv.toString('hex'),
data: encrypted.toString('hex'),
tag: authTag.toString('hex')
iv: iv.toString("hex"),
data: encrypted.toString("hex"),
tag: authTag.toString("hex"),
};
} catch (error) {
console.error('⚠️ Encryption exception:', {
console.error("Encryption exception:", {
message: error.message,
algorithm: 'aes-256-gcm',
keyLength: key ? key.length : 'undefined',
timestamp: new Date().toISOString()
algorithm: "aes-256-gcm",
keyLength: key ? key.length : "undefined",
timestamp: new Date().toISOString(),
});
throw new Error(`Encryption failed: ${error.message}`);
}
@@ -66,39 +41,40 @@ function encrypt(data, key) {
function decrypt(encryptedObj, key) {
try {
const algorithm = 'aes-256-gcm';
const iv = Buffer.from(encryptedObj.iv, 'hex');
const algorithm = "aes-256-gcm";
const iv = Buffer.from(encryptedObj.iv, "hex");
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAAD(Buffer.from('session-data'));
decipher.setAuthTag(Buffer.from(encryptedObj.tag, 'hex'));
decipher.setAAD(Buffer.from("session-data"));
decipher.setAuthTag(Buffer.from(encryptedObj.tag, "hex"));
let decrypted = decipher.update(Buffer.from(encryptedObj.data, 'hex'), null, 'utf8');
decrypted += decipher.final('utf8');
let decrypted = decipher.update(
Buffer.from(encryptedObj.data, "hex"),
null,
"utf8",
);
decrypted += decipher.final("utf8");
return JSON.parse(decrypted);
} catch (error) {
console.error('⚠️ Decryption exception:', {
console.error("Decryption exception:", {
message: error.message,
algorithm: 'aes-256-gcm',
keyLength: key ? key.length : 'undefined',
algorithm: "aes-256-gcm",
keyLength: key ? key.length : "undefined",
hasIV: !!encryptedObj.iv,
hasTag: !!encryptedObj.tag,
hasData: !!encryptedObj.data,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
throw new Error(`Decryption failed: ${error.message}`);
}
}
// For localhost requests, use a consistent API key so sessions persist
const LOCALHOST_API_KEY = 'localhost0123456789abcdef0123456789';
function isValidApiKey(apiKey) {
return typeof apiKey === 'string' && /^[0-9a-f]{32}$/i.test(apiKey);
return typeof apiKey === "string" && /^[0-9a-f]{32}$/i.test(apiKey);
}
function getSessionId(apiKey) {
return crypto.createHash('sha256').update(apiKey).digest('hex');
return crypto.createHash("sha256").update(apiKey).digest("hex");
}
function generateSalt() {
@@ -106,7 +82,7 @@ function generateSalt() {
}
function deriveKey(apiKey, salt) {
return crypto.pbkdf2Sync(apiKey, salt, 10000, 32, 'sha256');
return crypto.pbkdf2Sync(apiKey, salt, 100000, 32, "sha256");
}
// Create Express app
@@ -114,25 +90,25 @@ function createApp(devMode = false) {
const app = express();
// Trust proxy to get real client IP (needed for localhost detection)
app.set('trust proxy', true);
app.set("trust proxy", true);
// Middleware
app.use(express.json({ limit: MAX_SAMPLE_SIZE }));
app.use(express.static(path.join(__dirname, 'build')));
app.use(express.static(path.join(__dirname, "build")));
// Dev mode request logging middleware
if (devMode) {
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`📨 [${timestamp}] ${req.method} ${req.path}`);
if (req.method !== 'GET' && Object.keys(req.body).length > 0) {
const bodySize = Buffer.byteLength(JSON.stringify(req.body), 'utf8');
console.log(`[${timestamp}] ${req.method} ${req.path}`);
if (req.method !== "GET" && Object.keys(req.body).length > 0) {
const bodySize = Buffer.byteLength(JSON.stringify(req.body), "utf8");
console.log(` Request body size: ${(bodySize / 1024).toFixed(2)}KB`);
}
const originalJson = res.json;
res.json = function(data) {
console.log(` Response: ${res.statusCode}`);
res.json = function (data) {
console.log(` Response: ${res.statusCode}`);
return originalJson.call(this, data);
};
next();
@@ -148,7 +124,9 @@ function createApp(devMode = false) {
for (const [sessionId, session] of sessions.entries()) {
if (now - session.createdAt > MAX_SESSION_TTL) {
sessions.delete(sessionId);
console.log(`🧹 Cleaned up expired session: ${sessionId.substring(0, 8)}...`);
console.log(
`Cleaned up expired session: ${sessionId.substring(0, 8)}...`,
);
}
}
}
@@ -157,22 +135,15 @@ function createApp(devMode = false) {
setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
// API endpoints
app.post('/api/v1/upload', (req, res) => {
app.post("/api/v1/upload", (req, res) => {
try {
// Check if request is from localhost - if so, skip API key validation
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
const apiKey = req.headers["x-api-key"];
if (!isFromLocalhost) {
// Validate API key header for remote clients
if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' });
}
} else {
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
// Validate API key header
if (!apiKey || !isValidApiKey(apiKey)) {
return res
.status(403)
.json({ error: "Invalid or missing X-API-Key header" });
}
// Cleanup expired sessions before checking limits
@@ -181,26 +152,26 @@ function createApp(devMode = false) {
// Check session limits
if (sessions.size >= MAX_SESSIONS) {
return res.status(429).json({
error: 'Maximum number of sessions reached. Please try again later.',
error: "Maximum number of sessions reached. Please try again later.",
maxSessions: MAX_SESSIONS,
currentSessions: sessions.size
currentSessions: sessions.size,
});
}
const uploadedData = req.body;
// Validate that it's valid JSON
if (!uploadedData || typeof uploadedData !== 'object') {
return res.status(400).json({ error: 'Invalid JSON data' });
if (!uploadedData || typeof uploadedData !== "object") {
return res.status(400).json({ error: "Invalid JSON data" });
}
// Check data size
const dataSize = Buffer.byteLength(JSON.stringify(uploadedData), 'utf8');
const dataSize = Buffer.byteLength(JSON.stringify(uploadedData), "utf8");
if (dataSize > MAX_SAMPLE_SIZE) {
return res.status(413).json({
error: 'Sample data too large',
error: "Sample data too large",
maxSize: MAX_SAMPLE_SIZE,
receivedSize: dataSize
receivedSize: dataSize,
});
}
@@ -213,69 +184,62 @@ function createApp(devMode = false) {
const encryptedData = encrypt(uploadedData, key);
sessions.set(sessionId, {
salt: salt.toString('hex'),
salt: salt.toString("hex"),
encryptedData,
state: stateGuid,
createdAt: Date.now(),
accessed: false
accessed: false,
});
console.log(`📁 Session created: ${sessionId.substring(0, 8)}... (${sessions.size}/${MAX_SESSIONS})`);
console.log(
`Session created: ${sessionId.substring(0, 8)}... (${sessions.size}/${MAX_SESSIONS})`,
);
res.json({
message: 'Sample data uploaded successfully',
state: stateGuid,
sessionId: sessionId.substring(0, 8) + '...'
});
res.json({ message: "OK" });
} catch (error) {
console.error('⚠️ Upload endpoint exception occurred:', {
console.error("Upload endpoint exception occurred:", {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
// Provide more specific error messages based on error type
if (error.name === 'SyntaxError') {
if (error.name === "SyntaxError") {
return res.status(400).json({
error: 'Invalid JSON data format',
details: 'The uploaded data could not be parsed as valid JSON'
error: "Invalid JSON data format",
details: "The uploaded data could not be parsed as valid JSON",
});
} else if (error.message.includes('encrypt')) {
} else if (error.message.includes("encrypt")) {
return res.status(500).json({
error: 'Encryption failed',
details: 'Failed to encrypt session data. Please try again with a new API key.'
error: "Encryption failed",
details:
"Failed to encrypt session data. Please try again with a new API key.",
});
} else if (error.message.includes('PBKDF2')) {
} else if (error.message.includes("PBKDF2")) {
return res.status(500).json({
error: 'Key derivation failed',
details: 'Failed to derive encryption key from API key'
error: "Key derivation failed",
details: "Failed to derive encryption key from API key",
});
} else {
return res.status(500).json({
error: 'Upload processing failed',
details: 'An unexpected error occurred while processing your upload. Please try again.'
error: "Upload processing failed",
details:
"An unexpected error occurred while processing your upload. Please try again.",
});
}
}
});
app.get('/api/v1/sample', (req, res) => {
app.get("/api/v1/sample", (req, res) => {
try {
// Check if request is from localhost - if so, skip API key validation
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
const apiKey = req.headers["x-api-key"];
if (!isFromLocalhost) {
// Validate API key header for remote clients
if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' });
}
} else {
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
// Validate API key header
if (!apiKey || !isValidApiKey(apiKey)) {
return res
.status(403)
.json({ error: "Invalid or missing X-API-Key header" });
}
const sessionId = getSessionId(apiKey);
@@ -286,64 +250,62 @@ function createApp(devMode = false) {
}
// Decrypt data
const salt = Buffer.from(session.salt, 'hex');
const salt = Buffer.from(session.salt, "hex");
const key = deriveKey(apiKey, salt);
const decryptedData = decrypt(session.encryptedData, key);
// Remove session after first access (one-time use)
sessions.delete(sessionId);
console.log(`📤 Sample data retrieved and session cleared: ${sessionId.substring(0, 8)}...`);
console.log(
`Sample data retrieved and session cleared: ${sessionId.substring(0, 8)}...`,
);
res.json(decryptedData);
} catch (error) {
console.error('⚠️ Sample retrieval exception occurred:', {
console.error("Sample retrieval exception occurred:", {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
// Provide more specific error messages based on error type
if (error.message.includes('decrypt')) {
if (error.message.includes("decrypt")) {
return res.status(500).json({
error: 'Decryption failed',
details: 'Failed to decrypt session data. The session may be corrupted or the API key may be incorrect.'
error: "Decryption failed",
details:
"Failed to decrypt session data. The session may be corrupted or the API key may be incorrect.",
});
} else if (error.message.includes('JSON')) {
} else if (error.message.includes("JSON")) {
return res.status(500).json({
error: 'Data corruption detected',
details: 'The stored session data appears to be corrupted and cannot be parsed.'
error: "Data corruption detected",
details:
"The stored session data appears to be corrupted and cannot be parsed.",
});
} else if (error.name === 'TypeError') {
} else if (error.name === "TypeError") {
return res.status(500).json({
error: 'Session data format error',
details: 'The session data format is invalid or corrupted.'
error: "Session data format error",
details: "The session data format is invalid or corrupted.",
});
} else {
return res.status(500).json({
error: 'Sample retrieval failed',
details: 'An unexpected error occurred while retrieving sample data. The session may have been corrupted.'
error: "Sample retrieval failed",
details:
"An unexpected error occurred while retrieving sample data. The session may have been corrupted.",
});
}
}
});
app.get('/api/v1/state', (req, res) => {
app.get("/api/v1/state", (req, res) => {
try {
// Check if request is from localhost - if so, skip API key validation
const isFromLocalhost = isLocalhostRequest(req);
let apiKey = req.headers['x-api-key'];
const apiKey = req.headers["x-api-key"];
if (!isFromLocalhost) {
// Validate API key header for remote clients
if (!apiKey || !isValidApiKey(apiKey)) {
return res.status(403).json({ error: 'Invalid or missing X-API-Key header' });
}
} else {
// For localhost requests, use consistent API key for session persistence
if (!apiKey || !isValidApiKey(apiKey)) {
apiKey = LOCALHOST_API_KEY;
}
// Validate API key header
if (!apiKey || !isValidApiKey(apiKey)) {
return res
.status(403)
.json({ error: "Invalid or missing X-API-Key header" });
}
const sessionId = getSessionId(apiKey);
@@ -356,55 +318,56 @@ function createApp(devMode = false) {
res.json({ state: session.state });
} catch (error) {
console.error('⚠️ State retrieval exception occurred:', {
console.error("State retrieval exception occurred:", {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
// Provide more specific error messages
if (error.message.includes('API key')) {
if (error.message.includes("API key")) {
return res.status(403).json({
error: 'API key processing failed',
details: 'Failed to process the provided API key'
error: "API key processing failed",
details: "Failed to process the provided API key",
});
} else {
return res.status(500).json({
error: 'State retrieval failed',
details: 'An unexpected error occurred while retrieving session state. Please try again.'
error: "State retrieval failed",
details:
"An unexpected error occurred while retrieving session state. Please try again.",
});
}
}
});
// Status endpoint (no auth required) - detailed information
app.get('/api/v1/status', (req, res) => {
app.get("/api/v1/status", (req, res) => {
cleanupExpiredSessions(); // Cleanup on status check
res.json({
status: 'healthy',
status: "healthy",
sessions: {
current: sessions.size,
max: MAX_SESSIONS,
available: MAX_SESSIONS - sessions.size
available: MAX_SESSIONS - sessions.size,
},
limits: {
maxSessions: MAX_SESSIONS,
maxSampleSize: MAX_SAMPLE_SIZE,
maxSessionTTL: MAX_SESSION_TTL
maxSessionTTL: MAX_SESSION_TTL,
},
uptime: process.uptime()
uptime: process.uptime(),
});
});
// Health endpoint (no auth required) - simple OK response
app.get('/api/v1/health', (req, res) => {
res.type('text/plain').send('OK');
app.get("/api/v1/health", (req, res) => {
res.type("text/plain").send("OK");
});
// Serve React app for all other routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "build", "index.html"));
});
return app;
@@ -414,30 +377,41 @@ function createApp(devMode = false) {
if (require.main === module) {
const { values } = parseArgs({
options: {
'listen-addr': { type: 'string', short: 'h', default: process.env.LISTEN_ADDR || '127.0.0.1' },
'port': { type: 'string', short: 'p', default: process.env.LISTEN_PORT || '3000' },
'dev': { type: 'boolean', default: process.env.DEV_MODE === 'true' || false }
}
"listen-addr": {
type: "string",
short: "h",
default: process.env.LISTEN_ADDR || "127.0.0.1",
},
port: {
type: "string",
short: "p",
default: process.env.LISTEN_PORT || "3000",
},
dev: {
type: "boolean",
default: process.env.DEV_MODE === "true" || false,
},
},
});
const DEV_MODE = values.dev;
const app = createApp(DEV_MODE);
const PORT = parseInt(values.port);
const HOST = values['listen-addr'];
const HOST = values["listen-addr"];
app.listen(PORT, HOST, () => {
console.log(`JMESPath Playground Server running`);
if (DEV_MODE) {
console.log(` 🔧 Development Mode Enabled`);
console.log(" Development Mode Enabled");
}
// Show actual accessible URLs
if (HOST === '0.0.0.0') {
if (HOST === "0.0.0.0") {
console.log(` Listening on all interfaces:`);
const interfaces = os.networkInterfaces();
for (const [name, addrs] of Object.entries(interfaces)) {
for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) {
if (addr.family === "IPv4" && !addr.internal) {
console.log(` http://${addr.address}:${PORT}`);
}
}
@@ -450,17 +424,24 @@ if (require.main === module) {
console.log(`Configuration:`);
console.log(` Max Sessions: ${MAX_SESSIONS}`);
console.log(` Max Sample Size: ${(MAX_SAMPLE_SIZE / 1024 / 1024).toFixed(1)}MB`);
console.log(` Session TTL: ${(MAX_SESSION_TTL / 1000 / 60).toFixed(0)} minutes`);
console.log(
` Max Sample Size: ${(MAX_SAMPLE_SIZE / 1024 / 1024).toFixed(1)}MB`,
);
console.log(
` Session TTL: ${(MAX_SESSION_TTL / 1000 / 60).toFixed(0)} minutes`,
);
console.log(
" Security: AES-256-GCM encryption with PBKDF2 (100k iterations)",
);
// Show base API URL
let apiBaseUrl;
if (HOST === '0.0.0.0') {
if (HOST === "0.0.0.0") {
const interfaces = os.networkInterfaces();
let firstIP = '127.0.0.1';
let firstIP = "127.0.0.1";
outer: for (const addrs of Object.values(interfaces)) {
for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) {
if (addr.family === "IPv4" && !addr.internal) {
firstIP = addr.address;
break outer;
}
@@ -472,8 +453,7 @@ if (require.main === module) {
}
console.log(`API Base URL: ${apiBaseUrl}`);
console.log(`Security: AES-256-GCM encryption with PBKDF2 key derivation`);
});
}
module.exports = { createApp };
module.exports = { createApp };

View File

@@ -1,262 +1,46 @@
/* JMESPath Testing Tool Custom Styles */
/* JMESPath Testing Tool - Minimal Styles */
:root {
/* Common variables */
--font-mono: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
--accent-color: #007bff;
/* Button variants */
--btn-success: #28a745;
--btn-info: #17a2b8;
--btn-primary: #007bff;
--btn-danger: #dc3545;
--btn-secondary: #6c757d;
/* Common transitions */
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
/* Font families */
--font-sans: 'Noto Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'Noto Sans Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
--font-sans: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", "Noto Sans Mono", "Consolas", "Monaco", "Courier New", monospace;
}
/* Base font family */
body {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color var(--transition-normal), color var(--transition-normal);
}
/* Layout structure */
.vh-100 {
height: 100vh;
}
/* Header section styling - more compact */
.header-section {
/* Removed gradient background to fix text visibility */
margin: 0;
transition: background-color 0.3s ease;
}
/* Custom card styling */
.card {
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border-radius: 8px;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
#root {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.card-header {
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
color: #212529;
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
/* Scrollbar styling for a cleaner look */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
/* Input and textarea styling */
.jmespath-input, .json-input, .result-output {
font-family: var(--font-mono);
font-weight: 400;
transition: background-color var(--transition-normal), border-color var(--transition-normal), color var(--transition-normal);
::-webkit-scrollbar-track {
background: transparent;
}
.jmespath-input {
font-size: 14px;
padding: 10px;
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.json-input, .result-output {
font-size: 13px;
line-height: 1.4;
[data-mui-color-scheme="dark"] ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
/* Button styling */
.btn {
transition: all var(--transition-fast);
}
.btn:hover {
transform: translateY(-1px);
}
/* Footer styling */
footer {
flex-shrink: 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.header-section {
padding: 1.5rem 0 !important;
}
.display-4 {
font-size: 2rem;
}
.lead {
font-size: 1rem;
}
.btn-sm {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
}
.card-body textarea {
min-height: 300px !important;
}
}
/* Bootstrap theme integration */
[data-bs-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #6c757d;
--border: #dee2e6;
--border-input: #ced4da;
--success-bg: #d4edda;
--success-border: #c3e6cb;
--success-text: #155724;
--error-bg: #f8d7da;
--error-border: #f5c6cb;
--error-text: #721c24;
}
[data-bs-theme="dark"] {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-card: #323232;
--text-primary: #ffffff;
--text-secondary: #e9ecef;
--text-muted: #adb5bd;
--border: #495057;
--border-input: #6c757d;
--success-bg: #1e4a1e;
--success-border: #2c6d2c;
--success-text: #d4edda;
--error-bg: #4a1e1e;
--error-border: #6d2c2c;
--error-text: #f8d7da;
}
/* Apply theme colors */
body {
background-color: var(--bg-primary);
color: var(--text-secondary);
}
.card {
background-color: var(--bg-primary);
border-color: var(--border);
color: var(--text-primary);
}
.card-header {
background-color: var(--bg-secondary);
border-bottom-color: var(--border);
color: var(--text-primary);
}
.jmespath-input {
background-color: var(--bg-primary);
border-color: var(--border-input);
color: var(--text-secondary);
}
.json-input, .result-output {
background-color: var(--bg-secondary);
border-color: var(--border);
color: var(--text-secondary);
}
footer {
background-color: var(--bg-secondary);
color: var(--text-secondary);
}
footer.bg-light {
background-color: var(--bg-secondary) !important;
}
footer a {
color: var(--text-muted);
}
footer a:hover {
color: var(--text-secondary);
}
/* State styles */
.jmespath-input.success {
background-color: var(--success-bg) !important;
border-color: var(--success-border) !important;
color: var(--success-text) !important;
}
.jmespath-input.error {
background-color: var(--error-bg) !important;
border-color: var(--error-border) !important;
color: var(--error-text) !important;
}
.json-input.success {
background-color: var(--success-bg) !important;
border-color: var(--success-border) !important;
color: var(--success-text) !important;
}
.json-input.error {
background-color: var(--error-bg) !important;
border-color: var(--error-border) !important;
color: var(--error-text) !important;
}
/* Focus states */
.jmespath-input:focus {
border-color: var(--accent-color, #007bff);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.json-input:focus,
.result-output:focus {
background-color: var(--bg-primary);
border-color: var(--accent-color, #007bff);
color: var(--text-secondary);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
/* Placeholder colors */
.jmespath-input::placeholder,
.json-input::placeholder,
.result-output::placeholder {
color: var(--text-muted);
}
/* Alert styles */
.alert-danger {
background-color: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
/* Code block styles */
pre.bg-light {
background-color: var(--bg-secondary) !important;
color: var(--text-secondary) !important;
border-color: var(--border) !important;
}
code {
color: var(--text-secondary);
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}

View File

@@ -1,184 +0,0 @@
import React, { useState, useEffect } from 'react';
import Header from './components/Header';
import Footer from './components/Footer';
import MainPage from './components/MainPage';
import ApiKeyPage from './components/ApiKeyPage';
import './App.css';
// Utility function to generate a cryptographically secure API key
function generateApiKey() {
const array = new Uint8Array(16);
// Use crypto.getRandomValues if available (browser), fallback for tests
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(array);
} else {
// Fallback for test environments - not cryptographically secure
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
}
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
// JMESPath Testing Tool - Main Application Component
function App() {
const [currentPage, setCurrentPage] = useState('main'); // 'main' or 'apikey'
const [theme, setTheme] = useState(() => {
// Load theme from localStorage or default to 'auto'
return localStorage.getItem('theme') || 'auto';
});
const [showReloadButton, setShowReloadButton] = useState(false);
const [currentStateGuid, setCurrentStateGuid] = useState(null);
const [sampleData, setSampleData] = useState(null);
const [apiKey, setApiKey] = useState(() => {
// Load API key from localStorage or generate new one
const stored = localStorage.getItem('jmespath-api-key');
if (stored && /^[0-9a-f]{32}$/i.test(stored)) {
return stored;
}
const newKey = generateApiKey();
localStorage.setItem('jmespath-api-key', newKey);
return newKey;
});
// Theme management
useEffect(() => {
const applyTheme = (selectedTheme) => {
const effectiveTheme = selectedTheme === 'auto'
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: selectedTheme;
document.documentElement.setAttribute('data-bs-theme', effectiveTheme);
};
applyTheme(theme);
// Save theme preference
localStorage.setItem('theme', theme);
}, [theme]);
// Get headers for API requests
const getApiHeaders = () => {
const headers = {
'Accept': 'application/json'
};
// Only send API key for non-localhost requests
// For localhost, let server use its default LOCALHOST_API_KEY
if (window.location.hostname !== 'localhost' &&
window.location.hostname !== '127.0.0.1' &&
!window.location.hostname.startsWith('127.') &&
window.location.hostname !== '::1') {
headers['X-API-Key'] = apiKey;
}
return headers;
};
// Load sample data from API on startup and setup periodic state checking
useEffect(() => {
loadSampleData();
// Check for state changes every 5 seconds
const interval = setInterval(checkStateChange, 5000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiKey]);
// Check if state has changed (new data uploaded)
const checkStateChange = async () => {
try {
const response = await fetch('/api/v1/state', {
headers: getApiHeaders()
});
if (response.ok) {
const stateData = await response.json();
if (stateData.state && stateData.state !== currentStateGuid) {
setShowReloadButton(true);
}
}
} catch (error) {
// Silently handle state check errors
}
};
// Load sample data from API
const loadSampleData = async () => {
try {
setShowReloadButton(false);
const response = await fetch('/api/v1/sample', {
headers: getApiHeaders()
});
if (response.ok) {
const data = await response.json();
if (data) {
setSampleData(data);
}
// Update current state GUID
const stateResponse = await fetch('/api/v1/state', {
headers: getApiHeaders()
});
if (stateResponse.ok) {
const stateData = await stateResponse.json();
setCurrentStateGuid(stateData.state);
}
}
} catch (error) {
console.error('Failed to load sample data:', error);
}
};
// Regenerate API key
const regenerateApiKey = () => {
const newKey = generateApiKey();
setApiKey(newKey);
localStorage.setItem('jmespath-api-key', newKey);
setShowReloadButton(false);
setCurrentStateGuid(null);
};
const handleThemeChange = (newTheme) => {
setTheme(newTheme);
};
const handlePageChange = (newPage) => {
setCurrentPage(newPage);
};
return (
<div className="container-fluid vh-100 d-flex flex-column">
<Header
theme={theme}
onThemeChange={handleThemeChange}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
{/* Main Content Section - flex-grow to fill space */}
<div className="container-fluid flex-grow-1 d-flex flex-column" style={{ minHeight: 0 }}>
{currentPage === 'main' ? (
<MainPage
apiKey={apiKey}
showReloadButton={showReloadButton}
onReloadSampleData={loadSampleData}
initialSampleData={sampleData}
/>
) : (
<ApiKeyPage
apiKey={apiKey}
onRegenerateApiKey={regenerateApiKey}
/>
)}
</div>
<Footer />
</div>
);
}
export default App;

221
src/App.jsx Normal file
View File

@@ -0,0 +1,221 @@
import React, { useState, useEffect } from "react";
import {
CssBaseline,
Box,
useColorScheme,
} from "@mui/material";
import Header from "./components/Header";
import Footer from "./components/Footer";
import MainPage from "./components/MainPage";
import ApiKeyPage from "./components/ApiKeyPage";
import "./App.css";
// Utility function to generate a cryptographically secure API key
function generateApiKey() {
const array = new Uint8Array(16);
// Use crypto.getRandomValues if available (browser), fallback for tests
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
crypto.getRandomValues(array);
} else {
// Fallback for test environments - not cryptographically secure
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
}
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
// JMESPath Testing Tool - Main Application Component
function App() {
const [currentPage, setCurrentPage] = useState("main"); // 'main' or 'apikey'
const [theme, setTheme] = useState(() => {
// Load theme from localStorage or default to 'auto'
return localStorage.getItem("theme") || "auto";
});
const [shellType, setShellType] = useState(() => {
// Load shell type from localStorage or default to 'bash'
return localStorage.getItem("jmespath-shell-type") || "bash";
});
const [showReloadButton, setShowReloadButton] = useState(false);
const [currentStateGuid, setCurrentStateGuid] = useState(null);
const [jmespathExpression, setJmespathExpression] =
useState("people[0].name");
const [jsonData, setJsonData] = useState(`{
"people": [
{
"name": "John Doe",
"age": 30,
"city": "New York"
},
{
"name": "Jane Smith",
"age": 25,
"city": "Los Angeles"
}
],
"total": 2
}`);
const [apiKey, setApiKey] = useState(() => {
// Load API key from localStorage or generate new one
const stored = localStorage.getItem("jmespath-api-key");
if (stored && /^[0-9a-f]{32}$/i.test(stored)) {
return stored;
}
const newKey = generateApiKey();
localStorage.setItem("jmespath-api-key", newKey);
return newKey;
});
const getApiHeaders = () => ({
"Accept": "application/json",
"x-api-key": apiKey,
});
const { setMode } = useColorScheme();
// Load sample data from API on startup and setup periodic state checking
useEffect(() => {
// Sync initial theme from localStorage with MUI color scheme
const initialMode = theme === 'auto' ? 'system' : theme;
setMode(initialMode);
loadSampleData();
// Check for state changes every 5 seconds
const interval = setInterval(checkStateChange, 5000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiKey]);
// Check if state has changed (new data uploaded)
const checkStateChange = async () => {
try {
const response = await fetch("/api/v1/state", {
headers: getApiHeaders(),
});
if (response.ok) {
const stateData = await response.json();
if (stateData.state && stateData.state !== currentStateGuid) {
setShowReloadButton(true);
}
}
} catch (error) {
// Silently handle state check errors
}
};
// Load sample data from API
const loadSampleData = async () => {
try {
setShowReloadButton(false);
const response = await fetch("/api/v1/sample", {
headers: getApiHeaders(),
});
if (response.ok) {
const data = await response.json();
if (data) {
setJsonData(JSON.stringify(data, null, 2));
}
// Update current state GUID
const stateResponse = await fetch("/api/v1/state", {
headers: getApiHeaders(),
});
if (stateResponse.ok) {
const stateData = await stateResponse.json();
setCurrentStateGuid(stateData.state);
}
}
} catch (error) {
console.error("Failed to load sample data:", error);
}
};
// Regenerate API key
const regenerateApiKey = () => {
const newKey = generateApiKey();
setApiKey(newKey);
localStorage.setItem("jmespath-api-key", newKey);
setShowReloadButton(false);
setCurrentStateGuid(null);
};
const handleThemeChange = (newTheme) => {
setTheme(newTheme);
const muiMode = newTheme === "auto" ? "system" : newTheme;
setMode(muiMode);
localStorage.setItem("theme", newTheme);
};
const handlePageChange = (newPage) => {
setCurrentPage(newPage);
};
const handleShellTypeChange = (newShellType) => {
setShellType(newShellType);
localStorage.setItem("jmespath-shell-type", newShellType);
};
return (
<>
<CssBaseline />
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
bgcolor: "background.default",
}}
>
<Header
theme={theme}
onThemeChange={handleThemeChange}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
{/* Main Content Section - flex-grow to fill space */}
<Box
component="main"
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
height: "100%", // Force height for children
}}
>
{currentPage === "main" ? (
<MainPage
apiKey={apiKey}
showReloadButton={showReloadButton}
onReloadSampleData={loadSampleData}
jmespathExpression={jmespathExpression}
setJmespathExpression={setJmespathExpression}
jsonData={jsonData}
setJsonData={setJsonData}
/>
) : (
<ApiKeyPage
apiKey={apiKey}
onRegenerateApiKey={regenerateApiKey}
shellType={shellType}
onShellTypeChange={handleShellTypeChange}
/>
)}
</Box>
<Footer />
</Box>
</>
);
}
export default App;

View File

@@ -1,13 +1,27 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
import { vi } from 'vitest';
import '@testing-library/jest-dom';
// Mock localStorage
const localStorageMock = (function() {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => { store[key] = value.toString(); }),
clear: vi.fn(() => { store = {}; }),
removeItem: vi.fn((key) => { delete store[key]; })
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// Mock fetch for API calls
global.fetch = jest.fn();
global.fetch = vi.fn();
describe('App Component', () => {
beforeEach(() => {
fetch.mockClear();
vi.clearAllMocks();
// Mock successful API responses
fetch.mockImplementation((url) => {
if (url.includes('/api/v1/sample')) {
@@ -35,7 +49,7 @@ describe('App Component', () => {
describe('Basic Rendering', () => {
test('renders JMESPath Testing Tool title', () => {
render(<App />);
const titleElement = screen.getByRole('heading', { name: /JMESPath Testing Tool/i });
const titleElement = screen.getByText(/JMESPath Playground/i);
expect(titleElement).toBeInTheDocument();
});
@@ -76,11 +90,11 @@ describe('App Component', () => {
test('renders all toolbar buttons', () => {
render(<App />);
expect(screen.getByTitle('Load JSON object from file')).toBeInTheDocument();
expect(screen.getByTitle('Load JSON Lines log file')).toBeInTheDocument();
expect(screen.getByTitle('Load sample data')).toBeInTheDocument();
expect(screen.getByTitle('Format JSON')).toBeInTheDocument();
expect(screen.getByTitle('Clear all inputs')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Load from Disk/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Load Logs/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Load Sample/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Format/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Clear all inputs/i })).toBeInTheDocument();
});
});
@@ -94,7 +108,7 @@ describe('App Component', () => {
const resultArea = screen.getByPlaceholderText(/Results will appear here/i);
// Clear all inputs first to start fresh
const clearButton = screen.getByTitle('Clear all inputs');
const clearButton = screen.getByRole('button', { name: /Clear all inputs/i });
await user.click(clearButton);
// Set JSON data directly after clearing
@@ -139,7 +153,7 @@ describe('App Component', () => {
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
// Clear all inputs first
const clearButton = screen.getByTitle('Clear all inputs');
const clearButton = screen.getByRole('button', { name: /Clear all inputs/i });
await user.click(clearButton);
// Set invalid JSON directly
@@ -173,31 +187,55 @@ describe('App Component', () => {
test('renders theme switcher buttons', () => {
render(<App />);
expect(screen.getByTitle('Auto (follow system)')).toBeInTheDocument();
expect(screen.getByTitle('Light theme')).toBeInTheDocument();
expect(screen.getByTitle('Dark theme')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Auto/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Light/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Dark/i })).toBeInTheDocument();
});
test('switches to light theme when clicked', async () => {
const user = userEvent.setup();
render(<App />);
const lightButton = screen.getByTitle('Light theme');
const lightButton = screen.getByRole('button', { name: /Light/i });
await user.click(lightButton);
// Check if button becomes active
expect(lightButton).toHaveClass('btn-primary');
expect(lightButton).toHaveClass('Mui-selected');
});
test('switches to dark theme when clicked', async () => {
const user = userEvent.setup();
render(<App />);
const darkButton = screen.getByTitle('Dark theme');
const darkButton = screen.getByRole('button', { name: /Dark/i });
await user.click(darkButton);
// Check if button becomes active
expect(darkButton).toHaveClass('btn-primary');
expect(darkButton).toHaveClass('Mui-selected');
});
});
describe('Navigation', () => {
test('switches to API Keys page and back', async () => {
const user = userEvent.setup();
render(<App />);
// Find and click API Keys button in Header
// MUI Tooltip might set aria-label to title "API Key Management"
const apiKeyButton = screen.getByRole('button', { name: /API Key Management/i });
await user.click(apiKeyButton);
// Check if API Key Management title is visible
expect(screen.getByText(/API Key Management/i)).toBeInTheDocument();
expect(screen.getByText(/YOUR API KEY/i)).toBeInTheDocument();
// Find and click Home button to go back
// MUI Tooltip title "Back to Testing" becomes the accessible name
const homeButton = screen.getByRole('button', { name: /Back to Testing/i });
await user.click(homeButton);
// Check if we are back on main page
expect(screen.getByRole('heading', { name: /JMESPath Expression/i })).toBeInTheDocument();
});
});
@@ -208,7 +246,7 @@ describe('App Component', () => {
const jmespathInput = screen.getByPlaceholderText(/Enter JMESPath expression/i);
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
const clearButton = screen.getByTitle('Clear all inputs');
const clearButton = screen.getByRole('button', { name: /Clear all inputs/i });
// Add some content
await user.type(jmespathInput, 'test.expression');
@@ -227,7 +265,7 @@ describe('App Component', () => {
render(<App />);
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
const formatButton = screen.getByTitle('Format JSON');
const formatButton = screen.getByRole('button', { name: "Format" });
// Add minified JSON directly
fireEvent.change(jsonInput, { target: { value: '{"name":"Alice","age":30,"skills":["React","Node"]}' } });
@@ -246,7 +284,7 @@ describe('App Component', () => {
const user = userEvent.setup();
render(<App />);
const loadSampleButton = screen.getByTitle('Load sample data');
const loadSampleButton = screen.getByRole('button', { name: "Load Sample" });
const jsonInput = screen.getByPlaceholderText(/Enter JSON data here/i);
const jmespathInput = screen.getByPlaceholderText(/Enter JMESPath expression/i);
@@ -317,7 +355,7 @@ describe('App Component', () => {
const user = userEvent.setup();
render(<App />);
const loadObjectButton = screen.getByTitle('Load JSON object from file');
const loadObjectButton = screen.getByRole('button', { name: "Load from Disk" });
// Create a mock file
const file = new File(['{"test": "file data"}'], 'test.json', {

View File

@@ -1,96 +0,0 @@
import React, { useState } from 'react';
function ApiKeyPage({ apiKey, onRegenerateApiKey }) {
const [copySuccess, setCopySuccess] = useState(false);
const handleCopyToClipboard = async () => {
try {
await navigator.clipboard.writeText(apiKey);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = apiKey;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}
};
return (
<div className="row justify-content-center">
<div className="col-md-8">
<div className="card">
<div className="card-header">
<h5 className="mb-0">🔐 API Key Management</h5>
</div>
<div className="card-body">
<div className="mb-4">
<label className="form-label fw-bold">Your API Key:</label>
<div className="input-group">
<input
type="text"
className="form-control font-monospace"
value={apiKey}
readOnly
/>
<button
className={`btn ${copySuccess ? 'btn-success' : 'btn-outline-secondary'}`}
onClick={handleCopyToClipboard}
title="Copy API key to clipboard"
>
{copySuccess ? '✓ Copied!' : '📋 Copy'}
</button>
<button
className="btn btn-outline-primary"
onClick={onRegenerateApiKey}
title="Generate new API key"
>
🔄 Regenerate
</button>
</div>
<div className="form-text">
This API key is used to encrypt and authenticate data uploads from remote clients.
<strong>Note:</strong> Requests from localhost (127.0.0.1) do not require an API key.
</div>
</div>
<div className="mb-4">
<h6>📡 Remote Data Upload API</h6>
<p className="text-muted">
External tools can upload sample data remotely using the REST API.
For remote clients, the API key is required for authentication. Define two
environment variables in your <code>.bashrc</code>.
</p>
<pre className="bg-light p-3 rounded border">
<code>export JMESPATH_PLAYGROUND_API_URL={window.location.origin}<br/>export JMESPATH_PLAYGROUND_API_KEY={apiKey}</code>
</pre>
<p className="text-muted">Then, use the following <code>curl</code> command to upload your data:</p>
<pre className="bg-light p-3 rounded border">
<code>{`curl -s -X POST \\
-H "Content-Type: application/json" \\
-H "Accept: application/json" \\
-H "X-API-Key: $JMESPATH_PLAYGROUND_API_KEY" \\
--data @__JSON_FILE_NAME__ \\
"$\{JMESPATH_PLAYGROUND_API_URL}/api/v1/upload"`}</code>
</pre>
<div className="form-text">
Replace <code>{'__JSON_FILE_NAME__'}</code> with the path to your JSON file containing the sample data.
or use <code>-</code> to read from standard input.
<br />
<strong>For localhost clients:</strong> The X-API-Key should be omitted.
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default ApiKeyPage;

View File

@@ -0,0 +1,205 @@
import React, { useState } from "react";
import {
Box,
Typography,
Paper,
TextField,
Button,
Grid,
Tooltip,
IconButton,
ToggleButtonGroup,
ToggleButton,
Divider,
} from "@mui/material";
import {
ContentCopy as ContentCopyIcon,
Autorenew as AutorenewIcon,
Check as CheckIcon,
Key as KeyIcon,
} from "@mui/icons-material";
function CodeBlock({ code }) {
const [copySuccess, setCopySuccess] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error("Failed to copy to clipboard:", err);
}
};
return (
<Box sx={{ position: "relative", my: 2 }}>
<Paper
variant="outlined"
sx={{
p: 2,
pr: 6,
bgcolor: "action.hover",
fontFamily: "'Noto Sans Mono', monospace",
fontSize: "0.85rem",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
position: "relative",
borderColor: "divider",
}}
>
<code>{code}</code>
<Tooltip title={copySuccess ? "Copied!" : "Copy code"}>
<IconButton
size="small"
onClick={handleCopy}
sx={{
position: "absolute",
top: 8,
right: 8,
color: copySuccess ? "success.main" : "primary.main",
}}
>
{copySuccess ? (
<CheckIcon fontSize="small" />
) : (
<ContentCopyIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
</Paper>
</Box>
);
}
function ApiKeyPage({
apiKey,
onRegenerateApiKey,
shellType,
onShellTypeChange,
}) {
const [copySuccess, setCopySuccess] = useState(false);
const handleCopyToClipboard = async () => {
try {
await navigator.clipboard.writeText(apiKey);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error("Failed to copy to clipboard:", err);
}
};
return (
<Box sx={{ flexGrow: 1, py: 4, px: 2 }}>
<Grid container justifyContent="center">
<Grid size={{ xs: 12, md: 10, lg: 8 }}>
<Paper elevation={1} sx={{ p: { xs: 3, md: 5 }, bgcolor: "background.paper", border: 1, borderColor: "divider" }}>
<Typography variant="h5" gutterBottom sx={{ mb: 4, fontWeight: 700, display: "flex", alignItems: "center", gap: 1.5, color: "text.primary" }}>
<KeyIcon color="primary" /> API Key Management
</Typography>
<Box sx={{ mb: 6 }}>
<Typography variant="subtitle2" gutterBottom color="text.secondary">
YOUR API KEY
</Typography>
<Box sx={{ display: "flex", gap: 1.5, alignItems: "center" }}>
<TextField
fullWidth
value={apiKey}
slotProps={{
input: {
readOnly: true,
style: { fontFamily: "'Noto Sans Mono', monospace", fontSize: "0.9rem" },
},
}}
variant="outlined"
sx={{ "& .MuiOutlinedInput-root": { bgcolor: "background.paper" } }}
/>
<Tooltip title="Copy API Key">
<IconButton
onClick={handleCopyToClipboard}
color={copySuccess ? "success" : "primary"}
size="medium"
sx={{ border: 1, borderColor: "divider" }}
>
{copySuccess ? <CheckIcon /> : <ContentCopyIcon />}
</IconButton>
</Tooltip>
<Tooltip title="Regenerate Key">
<IconButton
onClick={onRegenerateApiKey}
color="primary"
size="medium"
sx={{ border: 1, borderColor: "divider" }}
>
<AutorenewIcon />
</IconButton>
</Tooltip>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5, display: "block" }}>
This key is stored locally in your browser. Use it to authenticate remote data uploads.
</Typography>
</Box>
<Divider sx={{ my: 4, borderColor: "divider" }} />
<Box sx={{ mb: 4 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
>
<Typography variant="h6" fontWeight="600" color="text.primary">Remote Upload API</Typography>
<ToggleButtonGroup
size="small"
value={shellType}
exclusive
onChange={(e, value) => value && onShellTypeChange(value)}
aria-label="shell type"
sx={{ "& .MuiToggleButton-root": { px: 2, py: 0.5 } }}
>
<ToggleButton value="bash">UNIX (Bash)</ToggleButton>
<ToggleButton value="powershell">Windows (PS)</ToggleButton>
</ToggleButtonGroup>
</Box>
<Typography variant="body2" color="text.secondary" paragraph>
Use this endpoint to upload data from external scripts. Set these environment variables:
</Typography>
<CodeBlock
code={
shellType === "bash"
? `export JMESPATH_PLAYGROUND_API_URL="${window.location.origin}"\nexport JMESPATH_PLAYGROUND_API_KEY="${apiKey}"`
: `$env:JMESPATH_PLAYGROUND_API_URL = "${window.location.origin}"\n$env:JMESPATH_PLAYGROUND_API_KEY = "${apiKey}"`
}
/>
<CodeBlock
code={
shellType === "bash"
? `curl -X POST "$JMESPATH_PLAYGROUND_API_URL/api/v1/upload" \\
-H "Accept: application/json" \\
-H "x-api-key: $JMESPATH_PLAYGROUND_API_KEY" \\
-d '{ "users": [ { "id": 1, "name": "Remote User" } ] }'`
: `Invoke-RestMethod -Method Post -Uri "$env:JMESPATH_PLAYGROUND_API_URL/api/v1/upload" \`
-Headers @{ "Accept" = "application/json"; "x-api-key" = $env:JMESPATH_PLAYGROUND_API_KEY } \`
-Body '{ "users": [ { "id": 1, "name": "Remote User" } ] }'`
}
/>
</Box>
</Paper>
</Grid>
</Grid>
</Box>
);
}
export default ApiKeyPage;

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { VERSION } from '../version';
function Footer() {
return (
<footer className="bg-light border-top mt-2 py-2 flex-shrink-0">
<div className="container">
<div className="row">
<div className="col-md-6">
<p className="mb-0 text-muted small">
<strong>JMESPath Testing Tool</strong> {VERSION === 'unknown' ? VERSION : `v${VERSION}`} - Created for testing and validating JMESPath expressions
</p>
</div>
<div className="col-md-6 text-md-end">
<p className="mb-0 text-muted small">
Licensed under <a href="https://opensource.org/licenses/MIT" target="_blank" rel="noopener noreferrer" className="text-decoration-none">MIT License</a> |
<a href="https://jmespath.org/" target="_blank" rel="noopener noreferrer" className="text-decoration-none ms-2">
Learn JMESPath
</a>
</p>
</div>
</div>
</div>
</footer>
);
}
export default Footer;

58
src/components/Footer.jsx Normal file
View File

@@ -0,0 +1,58 @@
import React from "react";
import { Box, Typography, Container, Link, Grid } from "@mui/material";
import { IS_RELEASE, VERSION } from "../version";
function Footer() {
return (
<Box
component="footer"
sx={{
py: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
flexShrink: 0,
}}
>
<Container maxWidth="xl">
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2" color="text.secondary">
<strong>JMESPath Testing Tool</strong>{" "}
{IS_RELEASE ? VERSION : `${VERSION}-dev`} - Created for
testing and validating JMESPath expressions
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }} sx={{ textAlign: { md: "right" } }}>
<Typography variant="body2" color="text.secondary">
Licensed under{" "}
<Link
href="https://opensource.org/licenses/MIT"
target="_blank"
rel="noopener noreferrer"
color="primary"
underline="hover"
sx={{ fontWeight: 500 }}
>
MIT License
</Link>{" "}
|{" "}
<Link
href="https://jmespath.org/"
target="_blank"
rel="noopener noreferrer"
color="primary"
underline="hover"
sx={{ ml: 1, fontWeight: 500 }}
>
Learn JMESPath
</Link>
</Typography>
</Grid>
</Grid>
</Container>
</Box>
);
}
export default Footer;

View File

@@ -1,72 +0,0 @@
import React from 'react';
function Header({ theme, onThemeChange, currentPage, onPageChange }) {
return (
<div className="header-section py-2">
<div className="container">
<div className="row">
<div className="col-12 text-center position-relative">
<h2 className="mb-1">JMESPath Testing Tool</h2>
{/* Right side controls - better positioning */}
<div className="position-absolute top-0 end-0 d-flex align-items-center gap-2">
{/* API Key Management Button - more prominent */}
<button
type="button"
className={`btn btn-sm ${
currentPage === 'apikey'
? 'btn-warning fw-bold'
: 'btn-outline-warning'
}`}
onClick={() => onPageChange(currentPage === 'main' ? 'apikey' : 'main')}
title="API Key Management"
>
🔐 API Keys
</button>
{/* Theme switcher with theme-aware classes */}
<div className="btn-group btn-group-sm" role="group" aria-label="Theme switcher">
<button
type="button"
className={`btn ${
theme === 'auto'
? 'btn-primary'
: 'btn-outline-secondary'
}`}
onClick={() => onThemeChange('auto')}
title="Auto (follow system)"
>
🌓 Auto
</button>
<button
type="button"
className={`btn ${
theme === 'light'
? 'btn-primary'
: 'btn-outline-secondary'
}`}
onClick={() => onThemeChange('light')}
title="Light theme"
>
Light
</button>
<button
type="button"
className={`btn ${
theme === 'dark'
? 'btn-primary'
: 'btn-outline-secondary'
}`}
onClick={() => onThemeChange('dark')}
title="Dark theme"
>
🌙 Dark
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default Header;

89
src/components/Header.jsx Normal file
View File

@@ -0,0 +1,89 @@
import React from "react";
import {
Box,
Typography,
Button,
ToggleButton,
ToggleButtonGroup,
Tooltip,
AppBar,
Toolbar,
Container,
Divider,
} from "@mui/material";
import KeyIcon from "@mui/icons-material/Key";
import HomeIcon from "@mui/icons-material/Home";
import BrightnessAutoIcon from "@mui/icons-material/BrightnessAuto";
import LightModeIcon from "@mui/icons-material/LightMode";
import DarkModeIcon from "@mui/icons-material/DarkMode";
function Header({ theme, onThemeChange, currentPage, onPageChange }) {
return (
<AppBar position="static" color="default" elevation={1} sx={{ borderBottom: 1, borderColor: "divider" }}>
<Container maxWidth="xl">
<Toolbar disableGutters sx={{ display: "flex", justifyContent: "space-between", height: 64 }}>
{/* Brand/Title */}
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="h5"
noWrap
component="div"
sx={{
fontWeight: 700,
color: "primary.main",
letterSpacing: ".05rem",
}}
>
JMESPath Playground
</Typography>
</Box>
{/* Right side controls */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{/* API Key Management Button */}
<Tooltip title={currentPage === "main" ? "API Key Management" : "Back to Testing"}>
<Button
variant={currentPage === "apikey" ? "contained" : "text"}
color={currentPage === "apikey" ? "primary" : "primary"}
size="medium"
startIcon={currentPage === "main" ? <KeyIcon /> : <HomeIcon />}
onClick={() => onPageChange(currentPage === "main" ? "apikey" : "main")}
>
{currentPage === "main" ? "API Keys" : "Home"}
</Button>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ my: 2, mx: 1 }} />
{/* Theme switcher */}
<ToggleButtonGroup
value={theme}
exclusive
onChange={(e, nextTheme) => nextTheme && onThemeChange(nextTheme)}
aria-label="theme switcher"
size="small"
>
<Tooltip title="Follow system theme">
<ToggleButton value="auto" aria-label="Auto">
<BrightnessAutoIcon sx={{ fontSize: "1.2rem" }} />
</ToggleButton>
</Tooltip>
<Tooltip title="Light mode">
<ToggleButton value="light" aria-label="Light">
<LightModeIcon sx={{ fontSize: "1.2rem" }} />
</ToggleButton>
</Tooltip>
<Tooltip title="Dark mode">
<ToggleButton value="dark" aria-label="Dark">
<DarkModeIcon sx={{ fontSize: "1.2rem" }} />
</ToggleButton>
</Tooltip>
</ToggleButtonGroup>
</Box>
</Toolbar>
</Container>
</AppBar>
);
}
export default Header;

View File

@@ -1,299 +0,0 @@
import React, { useState, useEffect } from 'react';
import jmespath from 'jmespath';
function MainPage({ apiKey, showReloadButton, onReloadSampleData, initialSampleData }) {
const [jmespathExpression, setJmespathExpression] = useState('people[0].name');
const [jsonData, setJsonData] = useState(`{
"people": [
{
"name": "John Doe",
"age": 30,
"city": "New York"
},
{
"name": "Jane Smith",
"age": 25,
"city": "Los Angeles"
}
],
"total": 2
}`);
const [result, setResult] = useState('');
const [error, setError] = useState('');
const [jsonError, setJsonError] = useState('');
// Use initial sample data when provided
useEffect(() => {
if (initialSampleData) {
setJsonData(JSON.stringify(initialSampleData, null, 2));
}
}, [initialSampleData]);
const evaluateExpression = () => {
try {
// Clear previous errors
setError('');
setJsonError('');
// Validate and parse JSON
let parsedData;
try {
parsedData = JSON.parse(jsonData);
} catch (jsonErr) {
setJsonError(`Invalid JSON: ${jsonErr.message}`);
setResult('');
return;
}
// Evaluate JMESPath expression
const queryResult = jmespath.search(parsedData, jmespathExpression);
// Format the result
if (queryResult === null || queryResult === undefined) {
setResult('null');
} else {
setResult(JSON.stringify(queryResult, null, 2));
}
} catch (jmesErr) {
setError(`JMESPath Error: ${jmesErr.message}`);
setResult('');
}
};
// Auto-evaluate when inputs change
useEffect(() => {
if (jmespathExpression && jsonData) {
evaluateExpression();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jmespathExpression, jsonData]);
const handleJmespathChange = (e) => {
setJmespathExpression(e.target.value);
};
const handleJsonChange = (e) => {
setJsonData(e.target.value);
};
const formatJson = () => {
try {
const parsed = JSON.parse(jsonData);
setJsonData(JSON.stringify(parsed, null, 2));
} catch (err) {
// If JSON is invalid, don't format
}
};
const clearAll = () => {
setJmespathExpression('');
setJsonData('');
setResult('');
setError('');
setJsonError('');
};
const loadSample = () => {
const sampleData = {
"users": [
{"name": "Alice", "age": 30, "city": "New York"},
{"name": "Bob", "age": 25, "city": "San Francisco"},
{"name": "Charlie", "age": 35, "city": "Chicago"}
],
"total": 3
};
setJsonData(JSON.stringify(sampleData, null, 2));
setJmespathExpression('users[?age > `30`].name');
};
const loadFromDisk = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
const parsed = JSON.parse(content);
setJsonData(JSON.stringify(parsed, null, 2));
} catch (error) {
alert('Invalid JSON file');
}
};
reader.readAsText(file);
}
};
input.click();
};
const loadLogFile = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.log,.jsonl,.ndjson';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
const lines = content.trim().split('\n');
const logs = lines.map(line => JSON.parse(line));
setJsonData(JSON.stringify(logs, null, 2));
setJmespathExpression('[*].message');
} catch (error) {
alert('Invalid JSON Lines file');
}
};
reader.readAsText(file);
}
};
input.click();
};
return (
<>
{/* Description paragraph */}
<div className="row mb-2">
<div className="col-12">
<p className="text-muted text-center mb-2 small">
Validate and test JMESPath expressions against JSON data in real-time.
Enter your JMESPath query and JSON data below to see the results instantly.
</p>
</div>
</div>
{/* Middle Section: JMESPath Expression Input */}
<div className="row mb-2">
<div className="col-12">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center py-2">
<h6 className="mb-0">
<i className="bi bi-search me-2"></i>
JMESPath Expression
</h6>
<div>
<button
className="btn btn-outline-success btn-sm me-2"
onClick={loadFromDisk}
title="Load JSON object from file"
>
📄 Load an Object
</button>
<button
className="btn btn-outline-info btn-sm me-2"
onClick={loadLogFile}
title="Load JSON Lines log file"
>
📋 Load a Log File
</button>
<button
className="btn btn-outline-primary btn-sm me-2"
onClick={loadSample}
title="Load sample data"
>
Load Sample
</button>
<button
className="btn btn-outline-secondary btn-sm me-2"
onClick={formatJson}
title="Format JSON"
>
Format JSON
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={clearAll}
title="Clear all inputs"
>
Clear All
</button>
</div>
</div>
<div className="card-body">
<input
type="text"
className={`form-control jmespath-input ${error ? 'error' : 'success'}`}
value={jmespathExpression}
onChange={handleJmespathChange}
placeholder="Enter JMESPath expression (e.g., people[*].name)"
/>
<div className={`alert mt-2 mb-0 d-flex justify-content-between align-items-center ${error ? 'alert-danger' : 'alert-success'}`}>
<small className="mb-0">{error || 'Expression is correct'}</small>
{showReloadButton && (
<button
className="btn btn-light btn-sm ms-2 border"
onClick={() => {
onReloadSampleData();
}}
title="New sample data is available"
>
<i className="bi bi-arrow-clockwise me-1"></i>
Reload Sample Data
</button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Lower Middle Section: Input and Output Areas */}
<div className="row flex-grow-1" style={{ minHeight: 0 }}>
{/* Left Panel: JSON Data Input */}
<div className="col-md-6">
<div className="card h-100 d-flex flex-column">
<div className="card-header py-2">
<h6 className="mb-0">
<i className="bi bi-file-earmark-code me-2"></i>
JSON Data
</h6>
</div>
<div className="card-body flex-grow-1 d-flex flex-column" style={{ minHeight: 0 }}>
<textarea
className={`form-control json-input flex-grow-1 ${jsonError ? 'error' : 'success'}`}
value={jsonData}
onChange={handleJsonChange}
placeholder="Enter JSON data here..."
style={{ minHeight: 0, resize: 'none' }}
/>
{jsonError && (
<div className="alert alert-danger mt-2 mb-0">
<small>{jsonError}</small>
</div>
)}
</div>
</div>
</div>
{/* Right Panel: Results */}
<div className="col-md-6">
<div className="card h-100 d-flex flex-column">
<div className="card-header py-2">
<h6 className="mb-0">
<i className="bi bi-output me-2"></i>
Results
</h6>
</div>
<div className="card-body flex-grow-1 d-flex flex-column" style={{ minHeight: 0 }}>
<textarea
className="form-control result-output flex-grow-1"
value={result}
readOnly
placeholder="Results will appear here..."
style={{ minHeight: 0, resize: 'none' }}
/>
</div>
</div>
</div>
</div>
</>
);
}
export default MainPage;

534
src/components/MainPage.jsx Normal file
View File

@@ -0,0 +1,534 @@
import React, { useState, useEffect } from "react";
import {
Box,
Typography,
Paper,
TextField,
Button,
Tooltip,
IconButton,
Alert,
Stack,
Divider,
} from "@mui/material";
import {
Search as SearchIcon,
DataObject as DataObjectIcon,
Output as OutputIcon,
UploadFile as UploadFileIcon,
FileOpen as FileOpenIcon,
Restore as RestoreIcon,
FormatAlignLeft as FormatAlignLeftIcon,
Clear as ClearIcon,
ContentCopy as ContentCopyIcon,
Download as DownloadIcon,
Check as CheckIcon,
Refresh as RefreshIcon,
} from "@mui/icons-material";
import Grid from "@mui/material/Grid";
import jmespath from "jmespath";
function MainPage({
showReloadButton,
onReloadSampleData,
jmespathExpression,
setJmespathExpression,
jsonData,
setJsonData,
}) {
const [result, setResult] = useState("");
const [error, setError] = useState("");
const [jsonError, setJsonError] = useState("");
const [copySuccess, setCopySuccess] = useState(false);
const evaluateExpression = () => {
try {
// Clear previous errors
setError("");
setJsonError("");
// Validate and parse JSON
let parsedData;
try {
parsedData = JSON.parse(jsonData);
} catch (jsonErr) {
setJsonError(`Invalid JSON: ${jsonErr.message}`);
setResult("");
return;
}
// Evaluate JMESPath expression
const queryResult = jmespath.search(parsedData, jmespathExpression);
// Format the result
if (queryResult === null || queryResult === undefined) {
setResult("null");
} else {
setResult(JSON.stringify(queryResult, null, 2));
}
} catch (jmesErr) {
setError(`JMESPath Error: ${jmesErr.message}`);
setResult("");
}
};
// Auto-evaluate when inputs change
useEffect(() => {
if (jmespathExpression && jsonData) {
evaluateExpression();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jmespathExpression, jsonData]);
const handleJmespathChange = (e) => {
setJmespathExpression(e.target.value);
};
const handleJsonChange = (e) => {
setJsonData(e.target.value);
};
const formatJson = () => {
try {
const parsed = JSON.parse(jsonData);
setJsonData(JSON.stringify(parsed, null, 2));
} catch (err) {
// If JSON is invalid, don't format
}
};
const clearAll = () => {
setJmespathExpression("");
setJsonData("");
setResult("");
setError("");
setJsonError("");
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(result);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error("Failed to copy!", err);
}
};
const downloadResult = () => {
const blob = new Blob([result], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "result.json";
a.click();
URL.revokeObjectURL(url);
};
const loadSample = () => {
const sampleData = {
users: [
{ name: "Alice", age: 30, city: "New York" },
{ name: "Bob", age: 25, city: "San Francisco" },
{ name: "Charlie", age: 35, city: "Chicago" },
],
total: 3,
};
setJsonData(JSON.stringify(sampleData, null, 2));
setJmespathExpression("users[?age > `30`].name");
};
const loadFromDisk = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
const parsed = JSON.parse(content);
setJsonData(JSON.stringify(parsed, null, 2));
} catch (error) {
alert("Invalid JSON file");
}
};
reader.readAsText(file);
}
};
input.click();
};
const loadLogFile = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".log,.jsonl,.ndjson";
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target.result;
const lines = content.trim().split("\n");
const logs = lines.map((line) => JSON.parse(line));
setJsonData(JSON.stringify(logs, null, 2));
setJmespathExpression("[*].message");
} catch (error) {
alert("Invalid JSON Lines file");
}
};
reader.readAsText(file);
}
};
input.click();
};
return (
<Box
sx={{
flexGrow: 1,
pt: 1,
pb: 3,
px: { xs: 2, md: 4 },
display: "flex",
flexDirection: "column",
minHeight: 0,
overflow: "hidden",
}}
>
<Box sx={{ mb: 2, flexShrink: 0 }}>
<Typography
variant="body2"
color="text.secondary"
align="left"
mt="1rem"
>
Validate and test JMESPath expressions against JSON data in real-time.
Enter your JMESPath query and JSON data below to see the results
instantly.
</Typography>
</Box>
<Paper
sx={{
flexShrink: 0,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
overflow: "hidden",
mb: 2
}}
>
<Box
sx={{
px: 2,
py: 1,
bgcolor: "action.hover",
borderBottom: 1,
borderColor: "divider",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<SearchIcon sx={{ fontSize: 20 }} color="primary" />
<Typography variant="subtitle2" color="text.primary">
JMESPath Expression
</Typography>
</Box>
</Box>
<Box sx={{ p: 1.5, mt: 0.5 }}>
<TextField
fullWidth
size="small"
placeholder="Enter JMESPath expression (e.g., people[*].name)"
value={jmespathExpression}
onChange={handleJmespathChange}
error={!!error}
helperText={error || " "}
sx={{
"& .MuiInputBase-root": {
fontFamily: "'Noto Sans Mono', monospace",
fontSize: "0.9rem",
},
"& .MuiFormHelperText-root": {
mt: 0.75,
mb: -0.5,
},
}}
/>
</Box>
</Paper>
<Grid container spacing={3} sx={{ flex: "1 1 0", minHeight: 0, height: 0 }}>
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
<Paper
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
minHeight: 0,
}}
>
<Box
sx={{
px: 2,
py: 1,
bgcolor: "action.hover",
borderBottom: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<DataObjectIcon sx={{ fontSize: 20 }} color="primary" />
<Typography variant="subtitle2" color="text.primary">
JSON Input
</Typography>
{showReloadButton && (
<Button
variant="contained"
color="secondary"
onClick={onReloadSampleData}
startIcon={<RefreshIcon fontSize="inherit" />}
size="small"
sx={{
ml: 1,
px: 1,
py: 0.25,
fontSize: "0.65rem",
textTransform: "none",
whiteSpace: "nowrap",
minWidth: "auto",
}}
>
Reload data
</Button>
)}
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Tooltip title="Load from Disk">
<IconButton
size="small"
onClick={loadFromDisk}
color="primary"
aria-label="Load from Disk"
>
<FileOpenIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Load Logs">
<IconButton
size="small"
onClick={loadLogFile}
color="primary"
aria-label="Load Logs"
>
<UploadFileIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Load Sample">
<IconButton
size="small"
onClick={loadSample}
color="primary"
aria-label="Load Sample"
>
<RestoreIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Format">
<IconButton
size="small"
onClick={formatJson}
color="primary"
aria-label="Format"
>
<FormatAlignLeftIcon fontSize="small" />
</IconButton>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Tooltip title="Clear all inputs">
<IconButton
size="small"
onClick={clearAll}
color="secondary"
aria-label="Clear all inputs"
>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
<Box sx={{ p: 2, flex: "1 1 0", display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden" }}>
<TextField
multiline
fullWidth
value={jsonData}
onChange={handleJsonChange}
placeholder="Enter JSON data here..."
variant="standard"
slotProps={{
input: {
disableUnderline: true,
style: {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "0.85rem",
lineHeight: 1.5,
height: "100%",
boxSizing: "border-box",
},
},
}}
sx={{
flex: "1 1 0",
display: "flex",
flexDirection: "column",
height: 0,
minHeight: 0,
"& .MuiInputBase-root": {
flex: "1 1 0",
display: "flex",
flexDirection: "column",
alignItems: "stretch",
height: "100%",
minHeight: 0,
},
"& .MuiInputBase-input": {
flexGrow: 1,
overflow: "auto !important",
height: "100% !important",
resize: "none",
padding: 0,
},
}}
/>
{jsonError && (
<Alert severity="error" sx={{ mt: 1, flexShrink: 0 }} variant="filled">
{jsonError}
</Alert>
)}
</Box>
</Paper>
</Grid>
<Grid size={{ xs: 12, md: 6 }} sx={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
<Paper
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
minHeight: 0,
}}
>
<Box
sx={{
px: 2,
py: 1,
bgcolor: "action.hover",
borderBottom: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<OutputIcon sx={{ mr: 1, fontSize: 20 }} color="primary" />
<Typography variant="subtitle2" color="text.primary">
Query Result
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Tooltip title="Copy to Clipboard">
<span>
<IconButton
size="small"
onClick={copyToClipboard}
disabled={!result || result === "null"}
color={copySuccess ? "success" : "primary"}
>
{copySuccess ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Download Result">
<span>
<IconButton
size="small"
onClick={downloadResult}
disabled={!result || result === "null"}
color="primary"
>
<DownloadIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Box>
<Box sx={{ p: 2, flex: "1 1 0", display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden" }}>
<TextField
multiline
fullWidth
value={result}
variant="standard"
placeholder="Results will appear here..."
slotProps={{
input: {
readOnly: true,
disableUnderline: true,
style: {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "0.85rem",
lineHeight: 1.5,
height: "100%",
boxSizing: "border-box",
},
},
}}
sx={{
flex: "1 1 0",
display: "flex",
flexDirection: "column",
height: 0,
minHeight: 0,
"& .MuiInputBase-root": {
flex: "1 1 0",
display: "flex",
flexDirection: "column",
alignItems: "stretch",
height: "100%",
minHeight: 0,
},
"& .MuiInputBase-input": {
flexGrow: 1,
overflow: "auto !important",
height: "100% !important",
resize: "none",
padding: 0,
},
}}
/>
</Box>
</Paper>
</Grid>
</Grid>
</Box>
);
}
export default MainPage;

View File

@@ -1,6 +1,11 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
background-color: #f8f9fa;
overflow: hidden;
}
code {
@@ -8,12 +13,6 @@ code {
monospace;
}
.container-fluid {
height: 100vh;
display: flex;
flex-direction: column;
}
.content-section {
flex: 1;
min-height: 0;
@@ -52,13 +51,6 @@ code {
color: var(--success-text-light);
}
.header-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem 0;
margin-bottom: 2rem;
}
/* Dark mode support for error states */
@media (prefers-color-scheme: dark) {
.error {

View File

@@ -1,12 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

15
src/index.jsx Normal file
View File

@@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { ThemeProvider } from "@mui/material";
import theme from "./theme";
import "./index.css";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<ThemeProvider theme={theme} defaultMode="system">
<App />
</ThemeProvider>
</React.StrictMode>
);

View File

@@ -1,46 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// Add TextEncoder/TextDecoder for Node.js compatibility
if (typeof TextEncoder === 'undefined') {
global.TextEncoder = require('util').TextEncoder;
}
if (typeof TextDecoder === 'undefined') {
global.TextDecoder = require('util').TextDecoder;
}
// Mock crypto.getRandomValues for test environment
if (typeof global.crypto === 'undefined') {
global.crypto = {
getRandomValues: (array) => {
// Simple predictable mock for testing
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
}
};
}
// Suppress console errors during tests
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
(args[0].includes('Warning: ReactDOMTestUtils.act is deprecated') ||
args[0].includes('Warning: An update to App inside a test was not wrapped in act'))
) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});

13
src/theme.js Normal file
View File

@@ -0,0 +1,13 @@
import { createTheme } from "@mui/material";
const theme = createTheme({
cssVariables: {
colorSchemeSelector: 'class',
},
colorSchemes: {
light: true,
dark: true,
},
});
export default theme;

25
vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'build',
},
test: {
globals: true,
environment: 'jsdom',
css: true,
},
});