Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44bb4b7458 | |||
| 794fd88e8d | |||
| 9f0d7ee70a | |||
| 4c964cdfeb | |||
| be6dc0de60 | |||
| dc9def4faf | |||
| 3dd352df92 | |||
| 57371feeb0 | |||
| d398c34aa5 | |||
| 452e6e74cb | |||
| b7df3e731f | |||
| 72d1be0bdc | |||
| 929d2ee5d2 | |||
| d0961c68fa | |||
| b1fd6da218 | |||
| 2a498124fe | |||
| 37c73ddd2b |
6
.github/copilot-instructions.md
vendored
6
.github/copilot-instructions.md
vendored
@@ -71,11 +71,9 @@ The UI generates an API key at startup then load the sample data at startup and
|
||||
|
||||
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.
|
||||
- Material UI v7 for building the user interface.
|
||||
- React for building the component logic.
|
||||
- JavaScript (ES6+) for scripting.
|
||||
- Bootstrap for styling and layout.
|
||||
- Express.js for serving the application and handling API requests.
|
||||
|
||||
### API
|
||||
|
||||
25
Dockerfile
25
Dockerfile
@@ -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
|
||||
|
||||
14
README.md
14
README.md
@@ -4,7 +4,7 @@ A React-based web application for testing and validating JMESPath expressions ag
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
@@ -43,6 +43,16 @@ A React-based web application for testing and validating JMESPath expressions ag
|
||||
|
||||
4. **Open your browser** and navigate to `http://localhost:3000`
|
||||
|
||||
### Development
|
||||
|
||||
For development with hot reload on component changes:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This runs both the React dev server (with hot reload) and the API server concurrently. The React app will proxy API requests to the backend server.
|
||||
|
||||
### Container Deployment
|
||||
|
||||
You can optionally run the application in a container:
|
||||
@@ -117,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
72
bin/Upload-JMESPath.ps1
Executable 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
|
||||
}
|
||||
@@ -2,23 +2,33 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
API_URL="https://jmespath-playground.koszewscy.waw.pl"
|
||||
JMESPATH_PLAYGROUND_API_URL="${JMESPATH_PLAYGROUND_API_URL:-http://localhost:3000}" # May be set in bash profile
|
||||
JMESPATH_PLAYGROUND_API_KEY="${JMESPATH_PLAYGROUND_API_KEY:-}" # Required if not localhost
|
||||
|
||||
JSON_FILE="-"
|
||||
ADD_HEADERS=()
|
||||
|
||||
function usage() {
|
||||
echo "Usage: $0 [--api-url <url>] [--json-file <file>]"
|
||||
exit 1
|
||||
echo
|
||||
echo "Options:"
|
||||
echo " --api-url <url> The base URL of the JMESPath Playground API (default: http://localhost:3000)"
|
||||
echo " --api-key <key> The API key for authentication (required if not localhost)"
|
||||
echo " --json-file <file> The JSON file to upload (default: stdin if not specified)"
|
||||
echo " -h, --help Show this help message and exit"
|
||||
echo
|
||||
echo "Environment Variables:"
|
||||
echo " JMESPATH_PLAYGROUND_API_URL Can be used to set the API URL"
|
||||
echo " JMESPATH_PLAYGROUND_API_KEY Can be used to set the API key"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--api-url)
|
||||
API_URL="$2"
|
||||
JMESPATH_PLAYGROUND_API_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--api-key)
|
||||
ADD_HEADERS+=("-H" "X-API-Key: $2")
|
||||
JMESPATH_PLAYGROUND_API_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--json-file)
|
||||
@@ -37,11 +47,12 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
ADD_HEADERS+=("-H" "X-API-Key: $JMESPATH_PLAYGROUND_API_KEY")
|
||||
|
||||
# Send the POST request
|
||||
curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
"${ADD_HEADERS[@]}" \
|
||||
--data @${JSON_FILE} \
|
||||
"$API_URL/api/v1/upload"
|
||||
|
||||
"$JMESPATH_PLAYGROUND_API_URL/api/v1/upload"
|
||||
|
||||
94
bin/upload-jmespath.mjs
Executable file
94
bin/upload-jmespath.mjs
Executable 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();
|
||||
@@ -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>
|
||||
16443
package-lock.json
generated
16443
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@@ -1,44 +1,35 @@
|
||||
{
|
||||
"name": "jmespath-playground",
|
||||
"version": "1.2.4",
|
||||
"version": "1.4.0",
|
||||
"description": "A React-based web application for testing JMESPath expressions against JSON data",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"start": "vite",
|
||||
"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",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"server": "node server.js --dev",
|
||||
"dev": "concurrently \"npm start\" \"node --watch server.js --dev\"",
|
||||
"build-image": "node scripts/build-image.js"
|
||||
},
|
||||
"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",
|
||||
"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%",
|
||||
@@ -61,6 +52,12 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"supertest": "^7.2.2"
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"concurrently": "^8.2.2",
|
||||
"jsdom": "^27.4.0",
|
||||
"supertest": "^7.2.2",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
81
scripts/build-image.js
Normal file → Executable file
81
scripts/build-image.js
Normal file → Executable file
@@ -2,6 +2,7 @@
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
function execCommand(command, description) {
|
||||
try {
|
||||
@@ -46,11 +47,77 @@ function getVersion() {
|
||||
return { version: `${packageJson.version}-dev`, isRelease: false };
|
||||
}
|
||||
|
||||
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.js [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.js # Builds for ${hostArch} only (host architecture)
|
||||
build-image.js --all-arch # Builds for both arm64 and amd64
|
||||
build-image.js --arch arm64 # Builds for arm64 only
|
||||
build-image.js --arch arm64 --arch amd64 # Explicitly specify both
|
||||
build-image.js -h # Show help`);
|
||||
}
|
||||
|
||||
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 { version, isRelease } = getVersion();
|
||||
|
||||
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
|
||||
@@ -63,16 +130,24 @@ function main() {
|
||||
`-t skoszewski/jmespath-playground:latest`
|
||||
].join(' ');
|
||||
|
||||
const buildCommand = `${containerTool} build --build-arg VERSION="${version}" --build-arg IS_RELEASE="${isRelease}" ${tags} .`;
|
||||
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.js # Builds for host architecture only`);
|
||||
console.log(` build-image.js --all-arch # Builds for both arm64 and amd64`);
|
||||
console.log(` build-image.js --arch arm64 # Builds for arm64 only`);
|
||||
console.log(` build-image.js --arch arm64 --arch amd64 # Explicitly specify both`);
|
||||
|
||||
if (isRelease) {
|
||||
console.log(`\nTo run the container:`);
|
||||
console.log(` ${containerTool} run -p 3000:3000 skoszewski/jmespath-playground:${version}`);
|
||||
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}`);
|
||||
@@ -80,7 +155,7 @@ function main() {
|
||||
}
|
||||
} else {
|
||||
console.log(`\nTo run the container:`);
|
||||
console.log(` ${containerTool} run -p 3000:3000 skoszewski/jmespath-playground:dev`);
|
||||
console.log(` ${containerTool} run --arch arm64 --name jmespathpg -p 3000:3000 skoszewski/jmespath-playground:dev`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
356
server.js
356
server.js
@@ -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,19 +82,38 @@ 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
|
||||
function createApp() {
|
||||
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(` Request body size: ${(bodySize / 1024).toFixed(2)}KB`);
|
||||
}
|
||||
|
||||
const originalJson = res.json;
|
||||
res.json = function (data) {
|
||||
console.log(` ✓ Response: ${res.statusCode}`);
|
||||
return originalJson.call(this, data);
|
||||
};
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Session storage
|
||||
const sessions = new Map();
|
||||
@@ -129,7 +124,9 @@ function createApp() {
|
||||
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)}...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,22 +135,15 @@ function createApp() {
|
||||
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
|
||||
@@ -162,26 +152,26 @@ function createApp() {
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,69 +184,62 @@ function createApp() {
|
||||
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);
|
||||
@@ -267,64 +250,62 @@ function createApp() {
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -337,55 +318,56 @@ function createApp() {
|
||||
|
||||
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;
|
||||
@@ -395,25 +377,41 @@ function createApp() {
|
||||
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' }
|
||||
}
|
||||
"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 app = createApp();
|
||||
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`);
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
@@ -426,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;
|
||||
}
|
||||
@@ -448,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 };
|
||||
|
||||
262
src/App.css
262
src/App.css
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
184
src/App.js
184
src/App.js
@@ -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
221
src/App.jsx
Normal 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;
|
||||
@@ -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', {
|
||||
@@ -1,102 +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:
|
||||
</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: ${apiKey}" \\
|
||||
--data @{{JSON_FILE_NAME}} \\
|
||||
"${window.location.origin}/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.
|
||||
<br />
|
||||
<strong>For localhost clients:</strong> The X-API-Key header is optional and can be omitted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert alert-info">
|
||||
<h6 className="alert-heading">ℹ️ How it works:</h6>
|
||||
<ul className="mb-0">
|
||||
<li>Remote clients require API key authentication for security</li>
|
||||
<li>Localhost clients (127.0.0.1) can access the API without authentication</li>
|
||||
<li>Your data is encrypted using AES-256-GCM with PBKDF2 key derivation</li>
|
||||
<li>Data is automatically cleared after first retrieval (one-time use)</li>
|
||||
<li>Sessions expire after 1 hour for security</li>
|
||||
<li>Maximum 100 concurrent sessions supported</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeyPage;
|
||||
205
src/components/ApiKeyPage.jsx
Normal file
205
src/components/ApiKeyPage.jsx
Normal 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;
|
||||
@@ -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
58
src/components/Footer.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Container, Link, Grid } from "@mui/material";
|
||||
import { 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>{" "}
|
||||
{VERSION === "unknown" ? VERSION : `v${VERSION}`} - 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;
|
||||
@@ -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
89
src/components/Header.jsx
Normal 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;
|
||||
@@ -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;
|
||||
535
src/components/MainPage.jsx
Normal file
535
src/components/MainPage.jsx
Normal file
@@ -0,0 +1,535 @@
|
||||
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={{
|
||||
mb: 1,
|
||||
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;
|
||||
@@ -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 {
|
||||
|
||||
12
src/index.js
12
src/index.js
@@ -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
15
src/index.jsx
Normal 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>
|
||||
);
|
||||
@@ -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
13
src/theme.js
Normal 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
25
vite.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user