Compare commits

26 Commits

Author SHA1 Message Date
sokol
9f51379df9 feat: JSON comments support + full-height layout
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
2026-02-20 16:40:13 +03:00
sokol
c8f45e4962 style: set environment panel to 5/12 width
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Successful in 45m28s
2026-02-20 16:27:34 +03:00
sokol
d89ce6ebbd style: maximize content width on large displays 2026-02-20 16:26:08 +03:00
sokol
8cc66ea9d6 style: adjust layout proportions 2026-02-20 16:10:38 +03:00
sokol
6e5a453c6f style: add gap-1 and p-1 to parameter rows 2026-02-20 16:02:43 +03:00
sokol
ead551ee6b style: ultra-compact Environment section
All checks were successful
CI / build-and-test (push) Successful in 29m26s
CI / build-and-test (pull_request) Successful in 26m23s
2026-02-20 14:36:02 +03:00
sokol
25f81cb8f7 style: make Environment section much more compact 2026-02-20 14:34:24 +03:00
sokol
482788a1d1 style: make Environment section more compact
All checks were successful
CI / build-and-test (push) Successful in 29m11s
2026-02-20 14:32:12 +03:00
sokol
9e313f5b86 feat: native deploy workflow without SSH
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
2026-02-20 13:55:53 +03:00
sokol
c735d7318f ci: fix E2E tests by starting dev server
All checks were successful
CI / build-and-test (push) Successful in 30m18s
2026-02-20 12:35:51 +03:00
sokol
9c94aa1df5 test: fix E2E download test for empty config protection
Some checks failed
CI / build-and-test (push) Failing after 24m44s
2026-02-20 11:57:56 +03:00
sokol
e4b44c7b5e refactor: improve type safety and code style
Some checks failed
CI / build-and-test (push) Failing after 24m31s
2026-02-20 11:22:56 +03:00
sokol
1c27b68965 feat: build Docker image locally without pushing to registry
Some checks failed
CI / build-and-test (push) Failing after 12m44s
2026-02-20 10:33:17 +03:00
sokol
083d7dd662 feat: add Gitea Actions CI/CD workflows
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-20 10:29:22 +03:00
sokol
7e1f7dd24c feat: prevent downloading empty config 2026-02-20 10:19:27 +03:00
sokol
1b3f3b3110 feat: disable Download button when no config is loaded 2026-02-20 10:16:28 +03:00
sokol
52232f6cde fix: center environment action buttons vertically 2026-02-19 23:35:38 +03:00
sokol
a6cc5a9827 refactor: complete application rewrite with modern UI 2026-02-19 22:55:26 +03:00
ssa
271b530fa1 Merge pull request 'ai' (#4) from ai into main
Reviewed-on: #4
2026-02-19 00:18:58 +03:00
sokol
0c97c4421c fix: JSON validation for unquoted @placeholders@ 2026-02-19 00:14:56 +03:00
sokol
529b506612 fix: handle unquoted @placeholders@ in JSON validation 2026-02-19 00:02:43 +03:00
ssa
81d3c51cb7 Merge pull request 'config: change external port to 11088' (#3) from ai into main
Reviewed-on: #3
2026-02-18 23:45:22 +03:00
sokol
93e3a66252 config: change external port to 11088 2026-02-18 23:40:21 +03:00
ssa
8ca531ca98 Merge pull request 'ai' (#2) from ai into main
Reviewed-on: #2
2026-02-18 23:27:18 +03:00
sokol
a10d6ecc29 fix: change default branch from 'ai' to 'main' in Docker config 2026-02-18 23:19:37 +03:00
sokol
0fe61acfe7 feat: add Docker deployment configuration 2026-02-18 23:12:11 +03:00
41 changed files with 4154 additions and 803 deletions

25
.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
node_modules
npm-debug.log
.git
.gitignore
*.md
.vscode
.idea
*.log
dist
build
coverage
.nyc_output
playwright-report
test-results
e2e
*.test.ts
*.test.tsx
*.spec.ts
*.spec.tsx
vitest.config.ts
playwright.config.ts
eslint.config.js
.qwen
*.ico
cgg-ico\ copy.png

View File

@@ -0,0 +1,99 @@
# Gitea Actions Workflows
## Workflows
### 1. CI (`ci.yml`)
**Triggers:** Push/PR to `main`, `rewrite`, `feat` branches
**Jobs:**
- Install dependencies
- Lint code
- Build application
- Run unit tests (Vitest)
- Run E2E tests (Playwright)
- Upload test results as artifacts
### 2. Docker Build (`docker-build.yml`)
**Triggers:**
- Tags matching `v*` (e.g., `v1.0.0`)
- Push to `main` branch
**Jobs:**
- Build React application
- Build Docker image locally
- Save image as tarball artifact
- Upload artifact for download (30 days retention)
**Output:**
- Docker image artifact: `configucci.tar`
- Can be downloaded and loaded with: `docker load -i configucci.tar`
### 3. Deploy (`deploy.yml`)
**Triggers:** Push to `main` branch
**Jobs:**
- Build React application
- Build Docker image locally
- Create docker-compose.yml configuration
- Deploy container on Gitea runner (port 11088)
- Health check to verify application is running
- Cleanup old Docker images
**No SSH required** - Everything runs natively on the Gitea Actions runner!
**Output:**
- Application available at: `http://<gitea-server>:11088`
- Container auto-restarts on failure
- Health check ensures successful deployment
## Setup Instructions
### 1. Enable Gitea Actions
Make sure Actions is enabled in your Gitea instance:
```ini
[actions]
ENABLED = true
```
### 2. Configure Runner
Ensure your Gitea runner has Docker and docker-compose installed:
```bash
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install docker-compose
sudo apt-get install docker-compose-plugin
```
**No secrets required** - Everything runs on the runner!
## Workflow Files Location
`.gitea/workflows/`
## Testing Workflows Locally
You can test workflows locally using [act](https://github.com/nektos/act):
```bash
# Install act
brew install act
# Run CI workflow locally
act push
# Run with specific job
act -j build-and-test
# Run with verbose output
act -v
```
## Badge Examples
Add these to your README.md:
```markdown
[![CI](https://git.six83.ru/ssa/configucci/actions/workflows/ci.yml/badge.svg)](https://git.six83.ru/ssa/configucci/actions/workflows/ci.yml)
[![Deploy](https://git.six83.ru/ssa/configucci/actions/workflows/deploy.yml/badge.svg)](https://git.six83.ru/ssa/configucci/actions/workflows/deploy.yml)
```

65
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main, rewrite, feat]
pull_request:
branches: [main, rewrite, feat]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Run unit tests
run: npm run test
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start dev server
run: npm run dev &
env:
HOST: 0.0.0.0
PORT: 5173
- name: Wait for dev server
run: |
echo "Waiting for dev server to start..."
for i in {1..30}; do
if curl -s http://localhost:5173 > /dev/null 2>&1; then
echo "Dev server is ready!"
exit 0
fi
sleep 1
done
echo "Dev server failed to start"
exit 1
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/

View File

@@ -0,0 +1,76 @@
name: Deploy to Server
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: configucci:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create docker-compose.yml
run: |
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
configucci:
image: configucci:latest
container_name: configucci
ports:
- "11088:80"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
EOF
- name: Stop existing containers
run: docker-compose down || true
- name: Start new container
run: docker-compose up -d
- name: Wait for application health
run: |
echo "Waiting for application to be healthy..."
for i in {1..30}; do
if curl -s http://localhost:11088 > /dev/null 2>&1; then
echo "Application is ready!"
exit 0
fi
sleep 2
done
echo "Application failed to start"
exit 1
- name: Cleanup old images
run: docker system prune -f

View File

@@ -0,0 +1,51 @@
name: Build Docker Image
on:
push:
tags:
- 'v*'
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: configucci:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Save Docker image as tarball
run: docker save configucci:latest -o configucci.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: configucci.tar
retention-days: 30

317
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,317 @@
# Configucci Docker Deployment Guide
## Quick Start
### Local Deployment (Windows/Linux/Mac)
```bash
# Make script executable (Linux/Mac only)
chmod +x deploy-docker.sh
# Deploy locally
./deploy-docker.sh
# Or on Windows (PowerShell)
bash deploy-docker.sh
```
### Remote Deployment (Linux Server)
```bash
# Deploy to remote server
./deploy-docker.sh user@your-server.com
# Example
./deploy-docker.sh root@192.168.1.100
```
---
## Manual Deployment
### 1. Build and Run Locally
```bash
# Build image
docker-compose build
# Start container
docker-compose up -d
# View logs
docker-compose logs -f
# Stop
docker-compose down
```
### 2. Deploy to Linux Server
#### Option A: Using Deployment Script (Recommended)
```bash
# From your local machine
./deploy-docker.sh user@your-server.com
```
#### Option B: Manual Steps on Server
```bash
# SSH to server
ssh user@your-server.com
# Install Docker (if not installed)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
exit
# SSH again (to apply group changes)
ssh user@your-server.com
# Clone repository
git clone https://git.six83.ru/ssa/configucci.git
cd configucci
# Build and run
docker-compose build
docker-compose up -d
# View logs
docker-compose logs -f
```
---
## Accessing the Application
After deployment, access the app at:
- **Local:** http://localhost:11088
- **Server:** http://your-server-ip:11088
---
## Useful Docker Commands
```bash
# View running containers
docker-compose ps
# View logs
docker-compose logs -f
# Restart application
docker-compose restart
# Stop application
docker-compose down
# Rebuild and restart
docker-compose build --no-cache
docker-compose up -d
# View resource usage
docker stats configucci-app
# Execute command in container
docker exec -it configucci-app sh
# Remove everything (container + image)
docker-compose down --rmi all
```
---
## Production Setup with SSL
### 1. Install Nginx Proxy Manager (Recommended)
```yaml
# Add to docker-compose.yml
version: '3.8'
services:
proxy:
image: jc21/nginx-proxy-manager:latest
ports:
- "80:80"
- "81:81"
- "443:443"
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
restart: unless-stopped
configucci:
# ... existing configucci config
```
Then access `http://your-server:81` to configure SSL via web UI.
### 2. Or Use Certbot Directly
```bash
# On host server (not in Docker)
sudo apt install certbot -y
# Get certificate
sudo certbot certonly --standalone -d your-domain.com
# Mount certificates in docker-compose.yml
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
```
---
## Environment Variables
You can customize the build by setting environment variables:
```bash
# In docker-compose.yml or .env file
GIT_URL=https://git.six83.ru/ssa/configucci.git
GIT_BRANCH=ai
```
---
## Troubleshooting
### Container Won't Start
```bash
# Check logs
docker-compose logs
# Check if port 80 is in use
sudo netstat -tlnp | grep :80
# Use different port
# Edit docker-compose.yml: ports: - "8080:80"
```
### Build Fails
```bash
# Clean build cache
docker-compose build --no-cache
# Remove all Docker resources
docker system prune -a
```
### Can't Access Application
```bash
# Check firewall
sudo ufw status
sudo ufw allow 80/tcp
# Check container is running
docker-compose ps
# Test from server
curl http://localhost
```
---
## Automated Deployment with CI/CD
Create `.gitlab-ci.yml` or GitHub Actions workflow:
```yaml
# Example: GitHub Actions
name: Deploy
on:
push:
branches: [ai]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/configucci
git pull
docker-compose build --no-cache
docker-compose up -d
```
---
## Backup and Restore
### Backup
```bash
# Backup configuration
tar -czf configucci-backup.tar.gz docker-compose.yml nginx.conf
```
### Restore
```bash
tar -xzf configucci-backup.tar.gz
docker-compose up -d
```
---
## Monitoring
### Install Docker Monitoring
```bash
# Install cAdvisor for container monitoring
docker run \
--volume=/:/rootfs:ro \
--volume=/var/run:/var/run:ro \
--volume=/sys:/sys:ro \
--volume=/var/lib/docker/:/var/lib/docker:ro \
--volume=/dev/disk/:/dev/disk:ro \
--publish=8080:8080 \
--name=cadvisor \
google/cadvisor:latest
```
Access at: `http://your-server:8080`
---
## Security Recommendations
1. **Use HTTPS** - Always use SSL in production
2. **Firewall** - Only open necessary ports
3. **Regular Updates** - Keep Docker and system updated
4. **Non-root User** - Run container as non-root
5. **Resource Limits** - Set CPU/memory limits in docker-compose.yml
```yaml
# Add resource limits
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
```
---
## Support
For issues, check:
- Docker logs: `docker-compose logs`
- Nginx logs: `docker exec configucci-app cat /var/log/nginx/error.log`
- Application health: `docker inspect --format='{{.State.Health.Status}}' configucci-app`

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
# Install git
RUN apk add --no-cache git
# Clone repository
ARG GIT_URL=https://git.six83.ru/ssa/configucci.git
ARG GIT_BRANCH=main
RUN git clone --depth 1 --branch ${GIT_BRANCH} ${GIT_URL} .
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

152
deploy-docker.sh Normal file
View File

@@ -0,0 +1,152 @@
#!/bin/bash
# Configucci Docker Deployment Script
# Usage: ./deploy-docker.sh [server]
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Configuration
SERVER="${1:-}"
APP_NAME="configucci"
GIT_BRANCH="main"
echo -e "${GREEN}==================================${NC}"
echo -e "${GREEN} Configucci Docker Deployment ${NC}"
echo -e "${GREEN}==================================${NC}"
# Function to deploy locally
deploy_local() {
echo -e "${YELLOW}Building Docker image...${NC}"
docker-compose build --no-cache
echo -e "${YELLOW}Stopping existing container (if any)...${NC}"
docker-compose down || true
echo -e "${YELLOW}Starting new container...${NC}"
docker-compose up -d
echo -e "${YELLOW}Waiting for application to start...${NC}"
sleep 5
echo -e "${GREEN}==================================${NC}"
echo -e "${GREEN} Deployment Complete! ${NC}"
echo -e "${GREEN}==================================${NC}"
echo ""
echo -e "Application URL: ${GREEN}http://localhost${NC}"
echo ""
echo -e "Useful commands:"
echo -e " View logs: ${YELLOW}docker-compose logs -f${NC}"
echo -e " Stop app: ${YELLOW}docker-compose down${NC}"
echo -e " Restart app: ${YELLOW}docker-compose restart${NC}"
echo -e " Rebuild: ${YELLOW}docker-compose build && docker-compose up -d${NC}"
}
# Function to deploy to remote server
deploy_remote() {
echo -e "${YELLOW}Deploying to server: ${SERVER}${NC}"
# Check if server is accessible
if ! ping -c 1 "$SERVER" &> /dev/null; then
echo -e "${RED}Cannot connect to server: ${SERVER}${NC}"
exit 1
fi
# Create deployment script on server
cat << 'EOF' > /tmp/deploy-configucci.sh
#!/bin/bash
set -e
APP_DIR="/opt/configucci"
GIT_URL="https://git.six83.ru/ssa/configucci.git"
GIT_BRANCH="main"
echo "Creating app directory..."
mkdir -p \$APP_DIR
cd \$APP_DIR
echo "Pulling latest code..."
if [ -d ".git" ]; then
git pull origin \$GIT_BRANCH
else
git clone --depth 1 --branch \$GIT_BRANCH \$GIT_URL .
fi
echo "Building Docker image..."
docker-compose build --no-cache
echo "Stopping existing container..."
docker-compose down || true
echo "Starting new container..."
docker-compose up -d
echo "Cleaning up old images..."
docker image prune -f
echo "Deployment complete!"
docker-compose ps
EOF
# Copy docker-compose.yml to server
echo -e "${YELLOW}Copying files to server...${NC}"
scp docker-compose.yml Dockerfile nginx.conf "$SERVER:/tmp/"
# Execute deployment on server
echo -e "${YELLOW}Executing deployment on server...${NC}"
ssh "$SERVER" << 'ENDSSH'
#!/bin/bash
set -e
APP_DIR="/opt/configucci"
cd $APP_DIR
# Copy files from /tmp
cp /tmp/docker-compose.yml .
cp /tmp/Dockerfile .
cp /tmp/nginx.conf .
# Build and deploy
docker-compose build --no-cache
docker-compose down || true
docker-compose up -d
# Cleanup
docker image prune -f
docker-compose ps
ENDSSH
echo -e "${GREEN}==================================${NC}"
echo -e "${GREEN} Remote Deployment Complete! ${NC}"
echo -e "${GREEN}==================================${NC}"
echo ""
echo -e "Server: ${GREEN}http://${SERVER}${NC}"
echo ""
echo -e "Useful commands:"
echo -e " SSH to server: ${YELLOW}ssh ${SERVER}${NC}"
echo -e " View logs: ${YELLOW}ssh ${SERVER} 'docker-compose logs -f'${NC}"
echo -e " Stop app: ${YELLOW}ssh ${SERVER} 'docker-compose down'${NC}"
}
# Check if Docker is available
if ! command -v docker &> /dev/null; then
echo -e "${RED}Docker is not installed or not in PATH${NC}"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}docker-compose is not installed or not in PATH${NC}"
exit 1
fi
# Deploy
if [ -z "$SERVER" ]; then
deploy_local
else
deploy_remote
fi

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
version: '3.8'
services:
configucci:
build:
context: .
dockerfile: Dockerfile
args:
GIT_URL: https://git.six83.ru/ssa/configucci.git
GIT_BRANCH: main
container_name: configucci-app
ports:
- "11088:80"
restart: unless-stopped
networks:
- configucci-network
volumes:
# Optional: persist nginx logs
- nginx-logs:/var/log/nginx
environment:
- TZ=Europe/Moscow
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
configucci-network:
driver: bridge
volumes:
nginx-logs:

View File

@@ -4,29 +4,32 @@ import * as fs from 'fs';
test.describe('Environment Management', () => {
test('should not allow removing DEFAULT environment', async ({ page }) => {
await page.goto('/');
await page.click('button:has-text("Create new")');
const removeButton = page.locator('button.btn-danger[title="Remove environment"]');
await page.click('button:has-text("New Config")');
await page.waitForTimeout(500);
const removeButton = page.locator('button[title="Remove environment"]');
await expect(removeButton).toBeDisabled();
});
test('should remove non-DEFAULT environment', async ({ page }) => {
await page.goto('/');
await page.click('button:has-text("Create new")');
await page.click('button:has-text("New Config")');
await page.waitForTimeout(500);
page.once('dialog', async dialog => { await dialog.accept('toRemove'); });
await page.click('button.btn-success[title="Add environment"]');
await page.click('button[title="Add environment"]');
await page.waitForTimeout(500);
await expect(page.locator('#environments option')).toHaveCount(2);
page.once('dialog', async dialog => { await dialog.accept(); });
await page.click('button.btn-danger[title="Remove environment"]');
await page.click('button[title="Remove environment"]');
await page.waitForTimeout(300);
await expect(page.locator('#environments option')).toHaveCount(1);
});
test('should create new environment and switch without errors', async ({ page }) => {
await page.goto('/');
await page.click('button:has-text("Create new")');
await page.click('button:has-text("New Config")');
await page.waitForTimeout(500);
page.once('dialog', async dialog => { await dialog.accept('env1'); });
await page.click('button.btn-success[title="Add environment"]');
await page.click('button[title="Add environment"]');
await page.waitForTimeout(500);
await expect(page.locator('#environments option')).toHaveCount(2);
await page.locator('#environments').selectOption({ index: 0 });
@@ -38,12 +41,13 @@ test.describe('Environment Management', () => {
test('should create multiple environments and switch between them', async ({ page }) => {
await page.goto('/');
await page.click('button:has-text("Create new")');
await page.click('button:has-text("New Config")');
await page.waitForTimeout(500);
page.once('dialog', async dialog => { await dialog.accept('env1'); });
await page.click('button.btn-success[title="Add environment"]');
await page.click('button[title="Add environment"]');
await page.waitForTimeout(500);
page.once('dialog', async dialog => { await dialog.accept('env2'); });
await page.click('button.btn-success[title="Add environment"]');
await page.click('button[title="Add environment"]');
await page.waitForTimeout(500);
await expect(page.locator('#environments option')).toHaveCount(3);
await page.locator('#environments').selectOption({ index: 0 });
@@ -57,10 +61,11 @@ test.describe('Environment Management', () => {
test('should add params and edit template manually', async ({ page }) => {
await page.goto('/');
await page.click('button:has-text("Create new")');
const nameInput = page.locator('input[placeholder="name"]').first();
const valueInput = page.locator('input[placeholder="value"]').first();
const addButton = page.locator('button.btn-success').first();
await page.click('button:has-text("New Config")');
await page.waitForTimeout(500);
const nameInput = page.locator('input[placeholder="Parameter name"]').first();
const valueInput = page.locator('input[placeholder="Parameter value"]').first();
const addButton = page.locator('button[title="Add parameter"]').first();
await nameInput.fill('host');
await valueInput.fill('localhost:8080');
await addButton.click();
@@ -69,48 +74,49 @@ test.describe('Environment Management', () => {
await valueInput.fill('9090');
await addButton.click();
await page.waitForTimeout(500);
await page.click('a:has-text("Content Template")');
await page.click('button:has-text("Content Template")');
await page.waitForTimeout(500);
await expect(page.locator('button:has-text("Edit")')).toBeVisible();
await page.click('button:has-text("Edit")');
await expect(page.locator('button:has-text("Edit Template")')).toBeVisible();
await page.click('button:has-text("Edit Template")');
await page.waitForTimeout(500);
const textarea = page.locator('textarea');
await expect(textarea).toBeVisible();
await textarea.fill('{\n "!!! host": "@host@",\n "!!! port": "@port@",\n "!!! custom": "@custom@"\n}');
await page.waitForTimeout(300);
await page.click('button:has-text("Save")');
await page.click('button:has-text("Save Changes")');
await page.waitForTimeout(500);
await expect(page.locator('button:has-text("Edit")')).toBeVisible();
await expect(page.locator('button:has-text("Edit Template")')).toBeVisible();
const pageContent = await page.content();
expect(pageContent).toContain('!!! custom');
});
test('should not duplicate params when placeholder already exists', async ({ page }) => {
await page.goto('/');
await page.click('button:has-text("Create new")');
const nameInput = page.locator('input[placeholder="name"]').first();
const valueInput = page.locator('input[placeholder="value"]').first();
const addButton = page.locator('button.btn-success').first();
await page.click('button:has-text("New Config")');
await page.waitForTimeout(500);
const nameInput = page.locator('input[placeholder="Parameter name"]').first();
const valueInput = page.locator('input[placeholder="Parameter value"]').first();
const addButton = page.locator('button[title="Add parameter"]').first();
await nameInput.fill('host');
await valueInput.fill('localhost:8080');
await addButton.click();
await page.waitForTimeout(500);
await page.click('a:has-text("Content Template")');
await page.click('button:has-text("Content Template")');
await page.waitForTimeout(500);
await page.click('button:has-text("Edit")');
await page.click('button:has-text("Edit Template")');
await page.waitForTimeout(300);
const textarea = page.locator('textarea');
await textarea.fill('{\n "!!! host": "@host@",\n "apiUrl": "http://@host@/api"\n}');
await page.waitForTimeout(300);
await page.click('button:has-text("Save")');
await page.click('button:has-text("Save Changes")');
await page.waitForTimeout(500);
await page.click('a:has-text("Env")');
await page.click('button:has-text("Env")');
await page.waitForTimeout(300);
await nameInput.fill('host');
await valueInput.fill('updated-host:9090');
await addButton.click();
await page.waitForTimeout(500);
await page.click('a:has-text("Content Template")');
await page.click('button:has-text("Content Template")');
await page.waitForTimeout(500);
const templateContent = await page.locator('.config-template-editor').textContent();
const hostKeyCount = (templateContent.match(/!!! host/g) || []).length;
@@ -119,10 +125,73 @@ test.describe('Environment Management', () => {
expect(hostPlaceholderCount).toBe(2);
});
test('should validate template with unquoted placeholders', async ({ page }) => {
await page.goto('/');
await page.click('button:has-text("New Config")');
await page.waitForTimeout(500);
// Add a parameter - use first() to get the new parameter inputs
await page.click('button[title="Add parameter"]');
await page.waitForTimeout(300);
const nameInput = page.locator('input[placeholder="Parameter name"]').last();
const valueInput = page.locator('input[placeholder="Parameter value"]').last();
const addButton = page.locator('button[title="Add parameter"]').last();
await nameInput.fill('port');
await valueInput.fill('8080');
await addButton.click();
await page.waitForTimeout(500);
// Go to Content Template and edit with unquoted placeholder
await page.click('button:has-text("Content Template")');
await page.waitForTimeout(300);
await page.click('button:has-text("Edit Template")');
await page.waitForTimeout(300);
// Fill template with unquoted @port@ placeholder
const textarea = page.locator('textarea');
await textarea.fill('{\n "Host": "@host@",\n "Port": @port@,\n "Url": "http://@host@:@port@/api"\n}');
await page.waitForTimeout(300);
// Check that Save button is enabled (validation passed)
const saveButton = page.locator('button:has-text("Save Changes")');
await expect(saveButton).toBeEnabled();
// Check that there's no JSON error
const errorAlert = page.locator('.bg-red-50');
await expect(errorAlert).not.toBeVisible();
// Save the template
await saveButton.click();
await page.waitForTimeout(500);
// Verify it was saved - should be in view mode with Edit button visible
const editButton = page.locator('button:has-text("Edit Template")');
await expect(editButton).toBeVisible();
// Verify the template content is displayed correctly
const codeContent = page.locator('.hljs');
await expect(codeContent).toBeVisible();
const content = await codeContent.textContent();
expect(content).toContain('@port@');
});
test('should download config file with correct filename', async ({ page }) => {
await page.goto('/');
await page.click('button:has-text("Create new")');
await page.click('button:has-text("New Config")');
await page.waitForTimeout(500);
// Add a parameter to make the config non-empty (Download button requires non-empty config)
await page.click('button[title="Add parameter"]');
await page.waitForTimeout(300);
const nameInput = page.locator('input[placeholder="Parameter name"]').last();
const valueInput = page.locator('input[placeholder="Parameter value"]').last();
const addButton = page.locator('button[title="Add parameter"]').last();
await nameInput.fill('host');
await valueInput.fill('localhost');
await addButton.click();
await page.waitForTimeout(500);
// Now download should work
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('button:has-text("Download")')

View File

@@ -3,21 +3,31 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
export default tseslint.config(
{
ignores: ['dist'],
},
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
])
)

36
nginx.conf Normal file
View File

@@ -0,0 +1,36 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# SPA routing - all routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Disable cache for index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}

1193
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
},
"dependencies": {
"bootstrap": "^5.3.3",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-highlight": "^0.15.0"
@@ -24,11 +25,14 @@
"@types/react-dom": "^18.3.5",
"@types/react-highlight": "^0.12.8",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.24",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,75 @@
/* App-specific styles - most styling is done with Tailwind */
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Highlight.js override for better dark mode support */
.hljs {
background: #fafafa !important;
padding: 1rem !important;
border-radius: 0.5rem;
}
/* Animation utilities */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
/* Focus visible for better accessibility */
:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Transition utilities */
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}

View File

@@ -1,17 +1,15 @@
import { useState } from 'react'
import './App.css'
import 'bootstrap/dist/css/bootstrap.css'
import { Env } from './models/Env'
import Environment from "./componets/env"
import Content from './componets/content'
import { FileChooser } from './componets/FileChooser'
import { Config } from "./models/Config"
import logo from './assets/cgg.png'
import { useState } from 'react';
import { File } from 'lucide-react';
import { Env } from './models/Env';
import { Environment } from './componets/env/Environment';
import { Content } from './componets/content/Content';
import { FileChooser } from './componets/FileChooser';
import { Config } from './models/Config';
class AppState {
private constructor(
public config: Config = new Config(),
) { }
) {}
static readonly Instance = new AppState();
@@ -23,7 +21,7 @@ class AppState {
// Simulate async save with 1 second delay
return await new Promise<number>((resolve) => {
setTimeout(() => {
console.log("Saved env:", env.name);
console.log('Saved env:', env.name);
resolve(0);
}, 1000);
});
@@ -41,9 +39,9 @@ function App() {
async function handleEnvChanged(env: Env) {
// Optimistic update - update React state immediately
setEnvs(prevEnvs => {
setEnvs((prevEnvs) => {
const newEnvs = [...prevEnvs];
const idx = newEnvs.findIndex(x => x.id === env.id);
const idx = newEnvs.findIndex((x) => x.id === env.id);
if (idx > -1) {
newEnvs[idx] = env;
}
@@ -51,10 +49,9 @@ function App() {
});
// Also update config.envs and template to keep them in sync
setConfig(prevConfig => {
setConfig((prevConfig) => {
const newConfig = new Config();
newConfig.envs = prevConfig.envs.map(e => e.id === env.id ? env : e);
// Update template JSON with params from this environment
newConfig.envs = prevConfig.envs.map((e) => (e.id === env.id ? env : e));
newConfig.template = prevConfig.template;
newConfig.updateTemplateFromEnv(env);
return newConfig;
@@ -70,8 +67,8 @@ function App() {
function handleEnvAdded(env: Env): number {
const newIdx = envs.length;
setEnvs(prevEnvs => [...prevEnvs, env]);
setConfig(prevConfig => {
setEnvs((prevEnvs) => [...prevEnvs, env]);
setConfig((prevConfig) => {
const newConfig = new Config();
newConfig.envs = [...prevConfig.envs, env];
newConfig.template = prevConfig.template;
@@ -81,63 +78,80 @@ function App() {
}
function handleEnvRemoved(envId: number) {
setEnvs(prevEnvs => prevEnvs.filter(e => e.id !== envId));
setConfig(prevConfig => {
setEnvs((prevEnvs) => prevEnvs.filter((e) => e.id !== envId));
setConfig((prevConfig) => {
const newConfig = new Config();
newConfig.envs = prevConfig.envs.filter(e => e.id !== envId);
newConfig.envs = prevConfig.envs.filter((e) => e.id !== envId);
newConfig.template = prevConfig.template;
return newConfig;
});
}
function handleTemplateSaved(newContent: string) {
setConfig(prevConfig => {
setConfig((prevConfig) => {
const newConfig = new Config();
newConfig.envs = prevConfig.envs;
newConfig.addTemplate(newContent);
newConfig.setTemplate(newContent);
return newConfig;
});
}
return (
<>
<main className="container-fluid m-2">
<div className="row mb-2">
<FileChooser onSelected={x => {
AppState.Instance.loadConfig(x);
setEnvs(x.envs);
setConfig(x);
}} config={config} />
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<main className="container mx-auto px-4 py-6 max-w-[1920px] min-h-[calc(100vh-48px)]">
{/* Header */}
<div className="mb-6">
<FileChooser
onSelected={(x) => {
AppState.Instance.loadConfig(x);
setEnvs(x.envs);
setConfig(x);
}}
config={config}
/>
</div>
{envs.length > 0 ?
(<div className="row">
<section id="env" className='col-4 me-1'>
<Environment
envs={envs}
onChanged={async (e) => await handleEnvChanged(e)}
onSelected={handleEnvSelected}
onAdd={handleEnvAdded}
onRemove={handleEnvRemoved} />
</section>
<section id="content" className="col-8 col-xl-7 border-start ms-1">
<Content env={currentEnv} config={config} onTemplateSaved={handleTemplateSaved} />
</section>
</div>)
:
(
<div className="row justify-content-center pt-5" >
<div className="col-1 pt-5">
<img src={logo} alt="" style={{ opacity: 0.2, transform: 'scale(1.8)' }} />
</div>
</div>
)}
{envs.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-[calc(100vh-200px)]">
{/* Environment Panel - 5/12 width */}
<section className="lg:col-span-5 xl:col-span-5 2xl:col-span-5">
<div className="sticky top-6 h-full">
<Environment
envs={envs}
onChanged={async (e) => await handleEnvChanged(e)}
onSelected={handleEnvSelected}
onAdd={handleEnvAdded}
onRemove={handleEnvRemoved}
/>
</div>
</section>
{/* Content Panel - 7/12 width */}
<section className="lg:col-span-7 xl:col-span-7 2xl:col-span-7 h-full">
<Content
env={currentEnv}
config={config}
onTemplateSaved={handleTemplateSaved}
/>
</section>
</div>
) : (
/* Empty State */
<div className="flex flex-col items-center justify-center py-20">
<div className="w-24 h-24 bg-gradient-to-br from-blue-400 to-blue-600 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
<File className="w-12 h-12 text-white opacity-80" />
</div>
<h2 className="text-2xl font-bold text-slate-700 mb-2">
No Configuration Loaded
</h2>
<p className="text-slate-500 text-center max-w-md">
Create a new configuration or upload an existing XML file to get started
</p>
</div>
)}
</main>
</>
)
</div>
);
}
export default App
export default App;

View File

@@ -34,7 +34,7 @@ export class EnvBuilder implements IBuilder<Env> {
private params(): this {
const tag = `<parameter name="{name}" value="{val}" />`;
for (let p of this.src.params) {
for (const p of this.src.params) {
this.stack.push(this.ident);
this.stack.push(tag
.replace("{name}", p.name ?? "!ERR!")

View File

@@ -11,7 +11,7 @@ export interface IBuilder<T> {
export class Builder {
public static getEnv(env: Env): IBuilder<Env> {
let b = new EnvBuilder();
const b = new EnvBuilder();
b.src = env;
return b;
};

View File

@@ -0,0 +1,42 @@
import { HTMLAttributes, forwardRef } from 'react';
export type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info';
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
size?: 'sm' | 'md';
}
const variantStyles: Record<BadgeVariant, string> = {
default: 'bg-slate-100 text-slate-700',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800',
};
const sizeStyles: Record<'sm' | 'md', string> = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-sm',
};
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
({ className = '', variant = 'default', size = 'sm', children, ...props }, ref) => {
return (
<span
ref={ref}
className={`
inline-flex items-center font-medium rounded-full
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
{...props}
>
{children}
</span>
);
}
);
Badge.displayName = 'Badge';

View File

@@ -0,0 +1,69 @@
import { LucideIcon } from 'lucide-react';
import { ButtonHTMLAttributes, forwardRef } from 'react';
export type ButtonVariant = 'primary' | 'success' | 'danger' | 'secondary' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: LucideIcon;
isLoading?: boolean;
}
const variantStyles: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 shadow-md hover:shadow-lg',
success: 'bg-green-600 text-white hover:bg-green-700 focus:ring-green-500 shadow-md hover:shadow-lg',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 shadow-md hover:shadow-lg',
secondary: 'bg-slate-200 text-slate-700 hover:bg-slate-300 focus:ring-slate-400',
ghost: 'bg-transparent text-slate-600 hover:bg-slate-100 focus:ring-slate-400',
};
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className = '',
variant = 'primary',
size = 'md',
icon: Icon,
isLoading = false,
disabled,
children,
...props
},
ref
) => {
const baseStyles = `
inline-flex items-center justify-center gap-2
font-medium rounded-lg
transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`;
return (
<button ref={ref} className={baseStyles} disabled={disabled || isLoading} {...props}>
{isLoading ? (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : Icon ? (
<Icon className="w-4 h-4" />
) : null}
{children}
</button>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,85 @@
import { HTMLAttributes, forwardRef } from 'react';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'bordered' | 'elevated';
padding?: 'none' | 'sm' | 'md' | 'lg';
}
const variantStyles: Record<string, string> = {
default: 'bg-white',
bordered: 'bg-white border-2 border-slate-200',
elevated: 'bg-white shadow-lg',
};
const paddingStyles: Record<string, string> = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
export const Card = forwardRef<HTMLDivElement, CardProps>(
({ className = '', variant = 'default', padding = 'md', children, ...props }, ref) => {
return (
<div
ref={ref}
className={`rounded-xl overflow-hidden transition-all duration-300 ${variantStyles[variant]} ${paddingStyles[padding]} ${className}`}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';
type CardHeaderProps = HTMLAttributes<HTMLDivElement>;
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className = '', children, ...props }, ref) => {
return (
<div
ref={ref}
className={`px-4 py-3 border-b border-slate-200 bg-slate-50 ${className}`}
{...props}
>
{children}
</div>
);
}
);
CardHeader.displayName = 'CardHeader';
type CardBodyProps = HTMLAttributes<HTMLDivElement>;
export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={`p-4 ${className}`} {...props}>
{children}
</div>
);
}
);
CardBody.displayName = 'CardBody';
type CardFooterProps = HTMLAttributes<HTMLDivElement>;
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
({ className = '', children, ...props }, ref) => {
return (
<div
ref={ref}
className={`px-4 py-3 border-t border-slate-200 bg-slate-50 ${className}`}
{...props}
>
{children}
</div>
);
}
);
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,49 @@
import { useState } from 'react';
import Highlight from 'react-highlight';
import 'highlight.js/styles/atom-one-light.css';
interface CodeBlockProps {
code: string;
language?: 'json' | 'xml' | 'javascript' | 'typescript' | 'css' | 'html' | 'plaintext';
showLineNumbers?: boolean;
maxHeight?: string;
className?: string;
}
export function CodeBlock({
code,
language = 'plaintext',
showLineNumbers = false,
maxHeight = 'none',
className = '',
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className={`relative group ${className}`}>
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={handleCopy}
className="px-2 py-1 text-xs bg-slate-800 text-white rounded hover:bg-slate-700 transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<div
className="rounded-lg overflow-hidden border border-slate-200"
style={{ maxHeight, overflow: maxHeight !== 'none' ? 'auto' : 'visible' }}
>
<Highlight className={`language-${language} text-sm ${showLineNumbers ? 'line-numbers' : ''}`}>
{code || '// Empty'}
</Highlight>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { InputHTMLAttributes, forwardRef, useState } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
icon?: React.ReactNode;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, hint, icon, className = '', id, ...props }, ref) => {
const [isFocused, setIsFocused] = useState(false);
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
const baseStyles = `
w-full px-3 py-2
border rounded-lg
transition-all duration-200
focus:outline-none focus:ring-2 focus:border-transparent
disabled:bg-slate-100 disabled:cursor-not-allowed
${error
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-slate-300 focus:ring-blue-500 focus:border-blue-500'
}
${isFocused ? 'ring-2 ring-blue-500 border-transparent bg-blue-50' : 'bg-white'}
${icon ? 'pl-10' : ''}
${className}
`;
return (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-slate-700 mb-1">
{label}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">
{icon}
</div>
)}
<input
ref={ref}
id={inputId}
className={baseStyles}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props}
/>
</div>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500">{hint}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,55 @@
import { SelectHTMLAttributes, forwardRef } from 'react';
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: { value: string | number; label: string; disabled?: boolean }[];
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ label, error, options, className = '', id, ...props }, ref) => {
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-');
const baseStyles = `
w-full px-3 py-2
border rounded-lg
bg-white cursor-pointer
transition-all duration-200
focus:outline-none focus:ring-2 focus:border-transparent
disabled:bg-slate-100 disabled:cursor-not-allowed
${error
? 'border-red-300 focus:ring-red-500'
: 'border-slate-300 focus:ring-blue-500'
}
${className}
`;
return (
<div className="w-full">
{label && (
<label htmlFor={selectId} className="block text-sm font-medium text-slate-700 mb-1">
{label}
</label>
)}
<select ref={ref} id={selectId} className={baseStyles} {...props}>
{options.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Select.displayName = 'Select';

View File

@@ -0,0 +1,75 @@
import { HTMLAttributes } from 'react';
export interface Tab {
id: string;
label: string;
badge?: string | number;
badgeVariant?: 'default' | 'success' | 'warning' | 'danger';
}
export interface TabsProps {
tabs: Tab[];
activeTab: string;
onChange: (tabId: string) => void;
className?: string;
}
export function Tabs({ tabs, activeTab, onChange, className = '', ...props }: TabsProps) {
return (
<div className={`border-b border-slate-200 ${className}`} {...props}>
<nav className="-mb-px flex gap-2" aria-label="Tabs">
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`
flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg
border-b-2 transition-all duration-200
${isActive
? 'border-blue-500 text-blue-600 bg-white'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 hover:bg-slate-50'
}
`}
aria-current={isActive ? 'page' : undefined}
>
{tab.label}
{tab.badge !== undefined && (
<span
className={`
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
${tab.badgeVariant === 'danger' || tab.badgeVariant === 'warning'
? 'bg-red-100 text-red-800'
: 'bg-slate-100 text-slate-600'
}
`}
>
{tab.badge}
</span>
)}
</button>
);
})}
</nav>
</div>
);
}
interface TabPanelProps extends HTMLAttributes<HTMLDivElement> {
isActive: boolean;
}
export function TabPanel({ isActive, children, className = '', ...props }: TabPanelProps) {
if (!isActive) return null;
return (
<div
className={`animate-fade-in ${className}`}
{...props}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,16 @@
export { Button } from './Button';
export type { ButtonVariant, ButtonSize } from './Button';
export { Input } from './Input';
export { Card, CardHeader, CardBody, CardFooter } from './Card';
export { Select } from './Select';
export { Badge } from './Badge';
export type { BadgeVariant } from './Badge';
export { Tabs, TabPanel } from './Tabs';
export type { Tab } from './Tabs';
export { CodeBlock } from './CodeBlock';

View File

@@ -1,41 +1,57 @@
import { Env } from "../models/Env";
import { ConfigReader } from "../models/ConfigReader";
import { Config } from "../models/Config";
import { ConfigBuilder } from "../builders/ConfigBuilder";
import { useRef } from 'react';
import { Upload, Download, FilePlus, File } from 'lucide-react';
import { Button } from '../components/ui';
import { Config } from '../models/Config';
import { ConfigReader } from '../models/ConfigReader';
import { ConfigBuilder } from '../builders/ConfigBuilder';
import { Env } from '../models/Env';
export function FileChooser(props: { onSelected: (x: Config) => void, config?: Config }) {
async function handleFile(x: React.ChangeEvent<HTMLInputElement>) {
let file = x.target.files![0];
interface FileChooserProps {
onSelected: (config: Config) => void;
config?: Config;
}
console.log(file.name, file.type, file.size, "supported:", ConfigReader.isSupportedFormat(file));
let reader = new ConfigReader();
let cfg = await reader.parseFromFile(file);
export function FileChooser({ onSelected, config }: FileChooserProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
console.log(file.name, file.type, file.size, 'supported:', ConfigReader.isSupportedFormat(file));
const reader = new ConfigReader();
const cfg = await reader.parseFromFile(file);
if (cfg !== null) {
props.onSelected(cfg);
onSelected(cfg);
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
function handleNew(){
let cfg = new Config();
cfg.addEnvs([new Env(0, "DEFAULT", [])]);
cfg.addTemplate("{}");
props.onSelected(cfg);
function handleNew() {
const cfg = new Config();
cfg.setEnvs([new Env(0, 'DEFAULT', [])]);
cfg.setTemplate('{}');
onSelected(cfg);
}
function handleDownload() {
if (!props.config) {
alert("No configuration loaded");
if (!config) {
alert('No configuration loaded');
return;
}
const xmlContent = ConfigBuilder.buildFullXml(props.config);
const xmlContent = ConfigBuilder.buildFullXml(config);
const filename = ConfigBuilder.generateFilename();
// Create blob and download
const blob = new Blob([xmlContent], { type: "text/xml" });
const blob = new Blob([xmlContent], { type: 'text/xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
@@ -44,26 +60,63 @@ export function FileChooser(props: { onSelected: (x: Config) => void, config?: C
URL.revokeObjectURL(url);
}
return (
<>
<div className="col-2">
<button className="btn btn-primary" onClick={handleNew} >Create new</button>
</div>
<div className="col-auto">
<button
className="btn btn-success"
onClick={handleDownload}
disabled={!props.config}
title="Download full config template"
>
Download
</button>
</div>
<div className="col-1">or</div>
const hasConfig = !!config && !config.isEmpty();
<div className="col">
<input className="form-control" type="file" id="formFile" onChange={handleFile} />
</div >
</>
return (
<div className="bg-white rounded-xl shadow-md p-4 border border-slate-200">
<div className="flex items-center gap-4 flex-wrap">
{/* Logo/Brand */}
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center">
<File className="w-6 h-6 text-white" />
</div>
<span className="font-bold text-xl text-slate-800">Configucci</span>
</div>
<div className="h-8 w-px bg-slate-200" />
{/* Action Buttons */}
<div className="flex items-center gap-2 flex-1">
<Button
variant="primary"
onClick={handleNew}
icon={FilePlus}
size="sm"
>
New Config
</Button>
<Button
variant="success"
onClick={handleDownload}
icon={Download}
size="sm"
disabled={!hasConfig}
title={hasConfig ? 'Download full config template' : 'Load or create a config first'}
>
Download
</Button>
<span className="text-slate-400 text-sm">or</span>
{/* File Upload */}
<div className="flex-1">
<label
className="flex items-center justify-center gap-2 px-4 py-2 border-2 border-dashed border-slate-300 rounded-lg cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all duration-200"
>
<Upload className="w-4 h-4 text-slate-400" />
<span className="text-sm text-slate-600">Upload XML Config</span>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".xml,text/xml"
onChange={handleFileChange}
/>
</label>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,29 +1,29 @@
import { useState, useEffect } from "react";
import Highlight from 'react-highlight';
import 'highlight.js/styles/far.css';
import { Config } from "../../models/Config";
import { useState, useEffect } from 'react';
import { Pencil, Save, XCircle, CheckCircle } from 'lucide-react';
import { Button, CodeBlock, Badge } from '../../components/ui';
import { Config } from '../../models/Config';
interface ConfigTemplateProps {
interface ConfigTemplateEditorProps {
config: Config;
onSaved: (newContent: string) => void;
}
export function ConfigTemplate(props: ConfigTemplateProps) {
export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorProps) {
const [mode, setMode] = useState<'view' | 'edit'>('view');
const [draftContent, setDraftContent] = useState(props.config.template.content);
const [originalContent, setOriginalContent] = useState(props.config.template.content);
const [draftContent, setDraftContent] = useState(config.template.content);
const [originalContent, setOriginalContent] = useState(config.template.content);
const [jsonError, setJsonError] = useState<string | null>(null);
// Sync draft when config changes (only in view mode)
useEffect(() => {
if (mode === 'view') {
setDraftContent(props.config.template.content);
setDraftContent(config.template.content);
}
}, [props.config.template.content, mode]);
}, [config.template.content, mode]);
function handleEdit() {
setOriginalContent(props.config.template.content);
setDraftContent(props.config.template.content);
setOriginalContent(config.template.content);
setDraftContent(config.template.content);
setJsonError(null);
setMode('edit');
}
@@ -34,33 +34,40 @@ export function ConfigTemplate(props: ConfigTemplateProps) {
setMode('view');
}
function handleSave() {
// Validate JSON before saving
function validateJson(value: string): boolean {
try {
JSON.parse(draftContent);
if (!value.trim()) {
setJsonError(null);
return true;
}
// Strip comments (// single-line and /* */ multi-line) and placeholders
const sanitizedValue = value
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* */ comments
.replace(/\/\/.*$/gm, '') // Remove // comments
.replace(/@[^@]+@/g, '1'); // Replace placeholders
JSON.parse(sanitizedValue);
setJsonError(null);
props.onSaved(draftContent);
setMode('view');
return true;
} catch (e) {
setJsonError((e as Error).message);
return false;
}
}
function handleSave() {
// Validate JSON before saving (with comment and placeholder support)
if (validateJson(draftContent)) {
onSaved(draftContent);
setMode('view');
}
}
function handleDraftChange(value: string) {
setDraftContent(value);
// Validate JSON on every change
try {
if (value.trim()) {
// Replace @placeholders@ with valid JSON values for validation
const sanitizedValue = value.replace(/"@?(\w+)@?"/g, '"__PLACEHOLDER__"');
JSON.parse(sanitizedValue);
setJsonError(null);
} else {
setJsonError(null);
}
} catch (e) {
setJsonError((e as Error).message);
}
validateJson(value);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
@@ -85,57 +92,86 @@ export function ConfigTemplate(props: ConfigTemplateProps) {
const isValidJson = jsonError === null;
return (
<div className="config-template-editor">
<div className="config-template-editor animate-fade-in">
{mode === 'view' ? (
<>
<div className="mb-2">
<button className="btn btn-primary btn-sm" onClick={handleEdit}>
Edit
</button>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="success">View Mode</Badge>
</div>
<Button
variant="primary"
size="sm"
onClick={handleEdit}
icon={Pencil}
>
Edit Template
</Button>
</div>
<Highlight className="language-json">
{props.config.template.content || "{}"}
</Highlight>
</>
<CodeBlock code={config.template.content || '{}'} language="json" maxHeight="500px" />
</div>
) : (
<>
<div className="mb-2 d-flex gap-2 align-items-center">
<button
className="btn btn-success btn-sm"
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="success"
size="sm"
onClick={handleSave}
disabled={!isValidJson}
icon={Save}
>
Save
</button>
<button
className="btn btn-secondary btn-sm"
Save Changes
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRevert}
icon={XCircle}
>
× Revert
</button>
<span className={`ms-2 ${isValidJson ? 'text-success' : 'text-danger'}`}>
{isValidJson ? 'Valid JSON' : 'Invalid JSON'}
</span>
Revert
</Button>
<Badge variant={isValidJson ? 'success' : 'danger'}>
{isValidJson ? (
<>
<CheckCircle className="w-3 h-3 mr-1" />
Valid JSON
</>
) : (
<>
<XCircle className="w-3 h-3 mr-1" />
Invalid JSON
</>
)}
</Badge>
</div>
{jsonError && (
<div className="alert alert-danger py-1 px-2 mb-2" style={{ fontSize: '0.875rem' }}>
{jsonError}
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-700 font-mono">{jsonError}</p>
</div>
)}
<textarea
className={`form-control font-monospace ${isValidJson ? 'border-success' : 'border-danger'}`}
className={`
w-full p-3 font-mono text-sm rounded-lg border-2
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
transition-all duration-200
${isValidJson
? 'border-green-300 bg-green-50'
: 'border-red-300 bg-red-50'
}
`}
value={draftContent}
onChange={(e) => handleDraftChange(e.target.value)}
onKeyDown={handleKeyDown}
rows={20}
style={{
fontFamily: 'monospace',
whiteSpace: 'pre',
overflowX: 'auto'
}}
style={{ whiteSpace: 'pre', overflowX: 'auto' }}
spellCheck={false}
/>
</>
</div>
)}
</div>
);

View File

@@ -1,157 +1,150 @@
import { useState } from "react";
import { Env } from "../../models/Env";
import Highlight from 'react-highlight'
import 'highlight.js/styles/far.css'
import { Builder } from "../../builders";
import { Config } from "../../models/Config";
import { ConfigTemplate } from "./ConfigTemplate";
import { useState } from 'react';
import { Tabs, TabPanel, CodeBlock } from '../../components/ui';
import { Env } from '../../models/Env';
import { Config } from '../../models/Config';
import { ConfigTemplateEditor } from './ConfigTemplate';
import { Builder } from '../../builders';
export function Content(props: { config: Config, env: Env, onTemplateSaved: (newContent: string) => void }) {
const [selectTab, setTab] = useState(ContentType.Env);
// Validate placeholders for warning badge
const missingPlaceholders = props.config.validatePlaceholders();
const hasValidationWarnings = missingPlaceholders.length > 0;
return (
<>
<ContentTabs onSelected={(id) => setTab(id)} selectedTab={selectTab} hasValidationWarnings={hasValidationWarnings} />
<div className="">
{selectTab == ContentType.Env ? (<ContentParams env={props.env} />) : ""}
{selectTab == ContentType.Json ? (<ConfigTemplate config={props.config} onSaved={props.onTemplateSaved} />) : ""}
{selectTab == ContentType.Raw ? (<ContentRaw config={props.config} env={props.env} />) : ""}
{selectTab == ContentType.Test ? (<ContentTest config={props.config} env={props.env} />) : ""}
</div>
</>
);
interface ContentProps {
config: Config;
env: Env;
onTemplateSaved: (newContent: string) => void;
}
enum ContentType {
Env = 0,
Json = 1,
Raw = 2,
Test = 3
export function Content({ config, env, onTemplateSaved }: ContentProps) {
const [activeTab, setActiveTab] = useState('env');
// Validate placeholders for warning badge
const missingPlaceholders = config.validatePlaceholders();
const hasValidationWarnings = missingPlaceholders.length > 0;
const tabs: Array<{ id: string; label: string; badge?: string | number; badgeVariant?: 'warning' | 'danger' }> = [
{ id: 'env', label: 'Env' },
{
id: 'template',
label: 'Content Template',
badge: hasValidationWarnings ? '!' : undefined,
badgeVariant: hasValidationWarnings ? 'warning' : undefined,
},
{ id: 'raw', label: 'Raw Template' },
{ id: 'test', label: 'Test-filled' },
];
return (
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden h-full flex flex-col">
<div className="flex-shrink-0">
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
</div>
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<TabPanel isActive={activeTab === 'env'}>
<ContentParams env={env} />
</TabPanel>
<TabPanel isActive={activeTab === 'template'}>
<ConfigTemplateEditor config={config} onSaved={onTemplateSaved} />
</TabPanel>
<TabPanel isActive={activeTab === 'raw'}>
<ContentRaw config={config} env={env} />
</TabPanel>
<TabPanel isActive={activeTab === 'test'}>
<ContentTest config={config} env={env} />
</TabPanel>
</div>
</div>
);
}
function ContentTabs(props: { onSelected: (id: ContentType) => void, selectedTab: ContentType, hasValidationWarnings: boolean }) {
function clickHandler(type: ContentType) {
props.onSelected(type);
}
function ContentParams({ env }: { env: Env }) {
const xml = Builder.getEnv(env).build();
function isActive(type: ContentType): string {
return type == props.selectedTab ? " active" : " ";
}
return (
<ul className="nav nav-pills nav-fill">
<li className="nav-item">
<a className={"nav-link" + isActive(ContentType.Env)} aria-current="page" href="#" onClick={() => clickHandler(ContentType.Env)}>Env</a>
</li>
<li className="nav-item">
<a className={"nav-link" + isActive(ContentType.Json)} href="#" onClick={() => clickHandler(ContentType.Json)} >
Content Template
{props.hasValidationWarnings && (
<span className="badge bg-warning text-dark ms-1">!</span>
)}
</a>
</li>
<li className="nav-item">
<a className={"nav-link" + isActive(ContentType.Raw)} href="#" onClick={() => clickHandler(ContentType.Raw)}>Raw template</a>
</li>
<li className="nav-item">
<a className={"nav-link" + isActive(ContentType.Test)} href="#" onClick={() => clickHandler(ContentType.Test)}>Test-filled template</a>
</li>
</ul>
)
return (
<div className="animate-fade-in">
<CodeBlock code={xml} language="xml" maxHeight="500px" />
</div>
);
}
function ContentRaw(props: { config: Config, env: Env }) {
const envsXml = Builder.getEnvs(props.config.envs);
const templateContent = props.config.template.content;
function ContentRaw({ config }: { config: Config; env: Env }) {
const envsXml = Builder.getEnvs(config.envs);
const templateContent = config.template.content;
const xml = `<engine>
const xml = `<engine>
${envsXml}
<template>
${templateContent}
</template>
</engine>`;
return (
<>
<Highlight className="language-xml">
{xml}
</Highlight>
</>
)
return (
<div className="animate-fade-in">
<CodeBlock code={xml} language="xml" maxHeight="500px" />
</div>
);
}
function ContentTest(props: { config: Config, env: Env }) {
const [selectedEnvId, setSelectedEnvId] = useState(props.env.id);
const selectedEnv = props.config.envs.find(e => e.id === selectedEnvId) ?? props.env;
function ContentTest({ config, env }: { config: Config; env: Env }) {
const [selectedEnvId, setSelectedEnvId] = useState(env.id ?? 0);
const selectedEnv = config.envs.find(e => e.id === selectedEnvId) ?? env;
const filledTemplate = fillTemplate(props.config, selectedEnv);
const filledTemplate = fillTemplate(config, selectedEnv);
return (
<>
<div className="mb-2">
<label className="form-label">Select Environment:</label>
<select
className="form-select w-auto d-inline-block"
value={selectedEnvId}
onChange={(e) => setSelectedEnvId(Number(e.target.value))}
>
{props.config.envs.map(env => (
<option key={env.id} value={env.id}>{env.name}</option>
))}
</select>
</div>
<Highlight className="language-json">
{filledTemplate}
</Highlight>
</>
)
const selectOptions = config.envs.map((e) => ({
value: e.id ?? 0,
label: e.name ?? 'Unknown',
}));
return (
<div className="animate-fade-in space-y-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-700">Select Environment:</label>
<select
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={selectedEnvId}
onChange={(e) => setSelectedEnvId(Number(e.target.value))}
>
{selectOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<CodeBlock code={filledTemplate} language="json" maxHeight="500px" />
</div>
);
}
function fillTemplate(config: Config, env: Env): string {
const defaultEnv = config.envs.find(e => e.name === "DEFAULT");
const paramMap = new Map<string, string>();
const defaultEnv = config.envs.find((e) => e.name === 'DEFAULT');
const paramMap = new Map<string, string>();
// First, load DEFAULT values as fallback
if (defaultEnv) {
for (const param of defaultEnv.params) {
if (param.name && param.value !== undefined) {
paramMap.set(param.name, param.value);
}
}
// Load DEFAULT values first
if (defaultEnv) {
for (const param of defaultEnv.params) {
if (param.name && param.value !== undefined) {
paramMap.set(param.name, param.value);
}
}
}
// Then, override with selected environment values (precedence)
for (const param of env.params) {
if (param.name && param.value !== undefined) {
paramMap.set(param.name, param.value);
}
// Override with selected environment values
for (const param of env.params) {
if (param.name && param.value !== undefined) {
paramMap.set(param.name, param.value);
}
}
let filledTemplate = config.template.content;
const placeholderRegex = /@(\w+)@/g;
let filledTemplate = config.template.content;
const placeholderRegex = /@(\w+)@/g;
filledTemplate = filledTemplate.replace(placeholderRegex, (_, paramName) => {
if (paramName === Config.ENV_NAME_PARAM) {
return env.name ?? "--NO-VALUE--";
}
return paramMap.get(paramName) ?? "--NO-VALUE--";
});
filledTemplate = filledTemplate.replace(placeholderRegex, (_, paramName) => {
if (paramName === Config.ENV_NAME_PARAM) {
return env.name ?? '--NO-VALUE--';
}
return paramMap.get(paramName) ?? '--NO-VALUE--';
});
return filledTemplate;
}
function ContentParams(props: { env: Env }) {
const bldr = Builder.getEnv(props.env);
return (
<Highlight className="language-xml">
{bldr.build()}
</Highlight>
)
return filledTemplate;
}

View File

@@ -1,125 +1,157 @@
import { useState, useEffect } from "react";
import { AddEvent, AppEvent, DelEvent, Env, UpdateEvent } from "../../models/Env";
import { EnvParam } from "../../models/EnvParam";
import { EnvironmentParam } from "./EnvironmentParam";
import { useState, useEffect } from 'react';
import { Plus, Minus } from 'lucide-react';
import { Button, Select, Card, CardBody } from '../../components/ui';
import { Env, AddEvent, RemoveEvent, UpdateEvent } from '../../models/Env';
import { EnvParam } from '../../models/EnvParam';
import { EnvironmentParam } from './EnvironmentParam';
export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, onSelected: (envId: number) => void, onAdd: (env: Env) => number, onRemove: (envId: number) => void }) {
const [currEnvId, setCurrEnvId] = useState(props.envs[0]?.id);
// Sync currEnvId when props.envs changes
useEffect(() => {
if (!props.envs.find(e => e.id === currEnvId)) {
setCurrEnvId(props.envs[0]?.id);
}
}, [props.envs, currEnvId]);
const currEnv = props.envs.find(e => e.id === currEnvId) ?? props.envs[0];
function handleParamChanged(e: AppEvent<EnvParam>) {
let isChanged = false;
let env = currEnv;
if (e instanceof DelEvent) {
env = currEnv.delParam(e.payload);
isChanged = true;
}
if (e instanceof AddEvent) {
env = currEnv.addParams(e.payload);
isChanged = true;
}
if (e instanceof UpdateEvent) {
env = currEnv.updateParams(e.payload);
isChanged = true;
}
if (isChanged) {
props.onChanged(env);
setCurrEnvId(env.id);
}
}
function handleAddEnv() {
const name = prompt("Enter new environment name:");
if (!name || name.trim() === "") return;
// Calculate next integer ID based on max existing ID
const maxId = props.envs.reduce((max, e) => Math.max(max, e.id ?? 0), -1);
const newId = maxId + 1;
const newEnv = new Env(
newId,
name.trim(),
[...currEnv.params]
);
// Parent synchronously adds the env and returns the index
const newIdx = props.onAdd(newEnv);
setCurrEnvId(newEnv.id);
props.onSelected(newIdx);
}
function handleRemoveEnv() {
if (currEnv.isDefault()) {
alert("Cannot remove DEFAULT environment");
return;
}
if (!confirm(`Remove environment "${currEnv.name}"?`)) return;
const idx = props.envs.findIndex(x => x.id === currEnv.id);
if (idx > -1 && currEnv.id !== undefined) {
// Let parent handle the removal
props.onRemove(currEnv.id);
const newIdx = Math.max(0, idx - 1);
const newEnv = props.envs[newIdx];
if (newEnv?.id !== undefined) {
setCurrEnvId(newEnv.id);
}
props.onSelected(newIdx);
}
}
const selectOptions = props.envs.map((x) => <option key={x.id} value={x.id} >{x.name}</option>);
const paramCtrls = currEnv.params.map(x =>
<EnvironmentParam key={`${currEnv.id}-${x.id}`}
param={new EnvParam(x.id, x.name, x.value)}
onChanged={handleParamChanged}
isNew={false} />);
return (
<>
<div className="row g-0">
<div className="col">
<select
id="environments"
name="environments"
aria-label="Environments"
className="form-select"
value={currEnvId}
onChange={x => {
let id = Number.parseInt(x.target.value);
setCurrEnvId(id);
props.onSelected(id);
}}>
{selectOptions}
</select>
</div>
<div className="col-auto ms-2">
<button className="btn btn-success" onClick={handleAddEnv} title="Add environment"></button>
</div>
<div className="col-auto ms-2">
<button className="btn btn-danger" onClick={handleRemoveEnv} title="Remove environment" disabled={currEnv.isDefault()}></button>
</div>
</div>
<div className="row">Params</div>
{paramCtrls}
<EnvironmentParam key={`${currEnv.id}-new`}
param={new EnvParam(-1, "", "")}
onChanged={handleParamChanged}
isNew={true}
/>
</>
);
interface EnvironmentProps {
envs: Env[];
onChanged: (env: Env) => void;
onSelected: (envId: number) => void;
onAdd: (env: Env) => number;
onRemove: (envId: number) => void;
}
export function Environment({ envs, onChanged, onSelected, onAdd, onRemove }: EnvironmentProps) {
const [currEnvId, setCurrEnvId] = useState<number>(envs[0]?.id ?? 0);
// Sync currEnvId when envs changes
useEffect(() => {
if (!envs.find(e => e.id === currEnvId)) {
setCurrEnvId(envs[0]?.id ?? 0);
}
}, [envs, currEnvId]);
const currEnv = envs.find(e => e.id === currEnvId) ?? envs[0];
function handleParamChanged(event: AddEvent<EnvParam> | RemoveEvent<EnvParam> | UpdateEvent<EnvParam>) {
let newEnv: Env = currEnv;
let isChanged = false;
if (event instanceof RemoveEvent) {
newEnv = currEnv.delParam(event.payload);
isChanged = true;
} else if (event instanceof AddEvent) {
newEnv = currEnv.addParams(event.payload);
isChanged = true;
} else if (event instanceof UpdateEvent) {
newEnv = currEnv.updateParams(event.payload);
isChanged = true;
}
if (isChanged) {
onChanged(newEnv);
setCurrEnvId(newEnv.id ?? 0);
}
}
function handleAddEnv() {
const name = prompt('Enter new environment name:');
if (!name || name.trim() === '') return;
// Calculate next integer ID based on max existing ID
const maxId = envs.reduce((max, e) => Math.max(max, e.id ?? 0), -1);
const newId = maxId + 1;
const newEnv = new Env(newId, name.trim(), [...currEnv.params]);
const newIdx = onAdd(newEnv);
setCurrEnvId(newEnv.id ?? 0);
onSelected(newIdx);
}
function handleRemoveEnv() {
if (currEnv.isDefault()) {
alert('Cannot remove DEFAULT environment');
return;
}
if (!confirm(`Remove environment "${currEnv.name}"?`)) return;
const idx = envs.findIndex(x => x.id === currEnv.id);
if (idx > -1 && currEnv.id !== undefined) {
onRemove(currEnv.id);
const newIdx = Math.max(0, idx - 1);
const newEnv = envs[newIdx];
if (newEnv?.id !== undefined) {
setCurrEnvId(newEnv.id);
}
onSelected(newIdx);
}
}
const selectOptions = envs.map((x) => ({
value: x.id ?? 0,
label: x.name ?? 'Unknown',
}));
const paramCtrls = currEnv.params.map((x) => (
<EnvironmentParam
key={`${currEnv.id}-${x.id}`}
param={new EnvParam(x.id, x.name, x.value)}
onChanged={handleParamChanged}
isNew={false}
/>
));
return (
<Card variant="bordered" padding="none" className="h-full overflow-hidden flex flex-col">
<CardBody className="space-y-2 flex flex-col h-full overflow-hidden">
{/* Environment Selector */}
<div className="flex-shrink-0 flex gap-2">
<div className="flex-1">
<Select
label="Environment"
value={currEnvId}
options={selectOptions}
onChange={(e) => {
const id = Number.parseInt(e.target.value);
setCurrEnvId(id);
onSelected(id);
}}
id="environments"
/>
</div>
<div className="flex flex-col justify-center gap-2 pt-6 flex-shrink-0">
<div className="flex gap-2">
<Button
variant="success"
size="sm"
onClick={handleAddEnv}
title="Add environment"
icon={Plus}
/>
<Button
variant="danger"
size="sm"
onClick={handleRemoveEnv}
title="Remove environment"
icon={Minus}
disabled={currEnv.isDefault()}
/>
</div>
</div>
</div>
{/* Parameters Section - Scrollable */}
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
<h3 className="text-sm font-semibold text-slate-700 mb-1 uppercase tracking-wide flex-shrink-0">
Parameters
</h3>
<div className="flex-1 overflow-y-auto space-y-0 pr-2 -mr-2">
{paramCtrls}
<EnvironmentParam
key={`${currEnv.id}-new`}
param={new EnvParam(-1, '', '')}
onChanged={handleParamChanged}
isNew={true}
/>
</div>
</div>
</CardBody>
</Card>
);
}

View File

@@ -1,68 +1,104 @@
import { useState } from "react";
import { EnvParam } from "../../models/EnvParam";
import { AppEvent } from "../../models/Env";
import { useState } from 'react';
import { Check, Minus } from 'lucide-react';
import { Button, Input } from '../../components/ui';
import { EnvParam } from '../../models/EnvParam';
import { AddEvent, RemoveEvent, UpdateEvent } from '../../models/Env';
export function EnvironmentParam(props: { param: EnvParam; onChanged: (e: AppEvent<EnvParam>) => void, isNew: boolean }) {
const [param, setParam] = useState(props.param);
const [isFocused, setIsFocused] = useState(false);
function doSet(x: string, act: (x: string) => void) {
act(x);
setParam(param.Changed(true));
}
function handleChange() {
if (!param.isChanged)
return;
let newParam = param.Changed(false);
if (!props.isNew) {
props.onChanged(AppEvent.update(newParam));
}
setParam(newParam);
}
function handleAdd() {
props.onChanged(AppEvent.add(param));
setParam(new EnvParam(0, "", ""));
}
function handleKeyUp(x: React.KeyboardEvent<HTMLInputElement>) {
if (x.key === "Enter") { handleChange(); }
}
return (
<div className={"row px-0" + (param.isChanged ? "border border-warning" : "")}
style={isFocused ? { backgroundColor: "lightskyblue", padding: "1px 0" } : { padding: "1px 0" }}>
<div className="col-4 mx-0 px-0">
<input type="text"
className="form-control"
style={{ backgroundColor: "rgba(170, 170, 247, 0.16)" }}
value={param.name}
onChange={x => doSet(x.target.value, (v) => param.name = v)}
onBlur={() => { handleChange(); setIsFocused(false); }}
onFocus={() => setIsFocused(true)}
onKeyUp={handleKeyUp}
placeholder="name"
aria-label="name" />
</div>
<div className="col mx-0 px-0">
<input type="text"
className="form-control"
value={param.value}
onChange={x => doSet(x.target.value, v => param.value = v)}
onBlur={() => { handleChange(); setIsFocused(false); }}
onFocus={() => setIsFocused(true)}
onKeyUp={handleKeyUp}
placeholder="value"
aria-label="value" />
</div>
<div className="col-1 mx-0 px-0" >
<button className="btn btn-success" hidden={!props.isNew} onClick={handleAdd}></button>
<button className="btn btn-warning" hidden={props.isNew} onClick={() => props.onChanged(AppEvent.del(param))} tabIndex={-1}></button>
</div>
</div>
);
interface EnvironmentParamProps {
param: EnvParam;
onChanged: (event: AddEvent<EnvParam> | RemoveEvent<EnvParam> | UpdateEvent<EnvParam>) => void;
isNew: boolean;
}
export function EnvironmentParam({ param, onChanged, isNew }: EnvironmentParamProps) {
const [localParam, setLocalParam] = useState(param);
const [isFocused, setIsFocused] = useState(false);
function updateParam(updates: Partial<EnvParam>) {
const updated = localParam.update(updates).markChanged(true);
setLocalParam(updated);
}
function handleChange() {
if (!localParam.isChanged) return;
const savedParam = localParam.markChanged(false);
if (!isNew) {
onChanged(UpdateEvent.update(savedParam));
}
setLocalParam(savedParam);
}
function handleAdd() {
onChanged(AddEvent.add(localParam));
setLocalParam(new EnvParam(0, '', ''));
}
function handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
handleChange();
}
}
const isChangedClass = localParam.isChanged ? 'ring-2 ring-yellow-400 border-yellow-400' : '';
const focusedClass = isFocused ? 'bg-blue-50' : '';
return (
<div
className={`
grid grid-cols-12 gap-1 p-1 rounded-lg transition-all duration-200
${isChangedClass}
${focusedClass ? 'bg-blue-50' : 'bg-white'}
hover:bg-slate-50
`}
>
<div className="col-span-4">
<Input
value={localParam.name ?? ''}
onChange={(e) => updateParam({ name: e.target.value })}
onBlur={() => { handleChange(); setIsFocused(false); }}
onFocus={() => setIsFocused(true)}
onKeyUp={handleKeyUp}
placeholder="Parameter name"
className="text-sm"
/>
</div>
<div className="col-span-7">
<Input
value={localParam.value ?? ''}
onChange={(e) => updateParam({ value: e.target.value })}
onBlur={() => { handleChange(); setIsFocused(false); }}
onFocus={() => setIsFocused(true)}
onKeyUp={handleKeyUp}
placeholder="Parameter value"
className="text-sm"
/>
</div>
<div className="col-span-1 flex items-center justify-center">
{isNew ? (
<Button
variant="success"
size="sm"
onClick={handleAdd}
title="Add parameter"
icon={Check}
className="px-2"
/>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => onChanged(new RemoveEvent(localParam))}
title="Remove parameter"
icon={Minus}
className="px-2 text-red-600 hover:text-red-700 hover:bg-red-50"
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gradient-to-br from-slate-50 to-slate-100 min-h-screen;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700
focus:ring-primary-500 shadow-md hover:shadow-lg;
}
.btn-success {
@apply btn bg-success-600 text-white hover:bg-success-700
focus:ring-success-500 shadow-md hover:shadow-lg;
}
.btn-danger {
@apply btn bg-danger-600 text-white hover:bg-danger-700
focus:ring-danger-500 shadow-md hover:shadow-lg;
}
.btn-secondary {
@apply btn bg-slate-200 text-slate-700 hover:bg-slate-300
focus:ring-slate-400 shadow-sm;
}
.btn-sm {
@apply px-3 py-1.5 text-sm;
}
.btn-icon {
@apply p-2 rounded-lg transition-all duration-200
hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-primary-500;
}
.input {
@apply w-full px-3 py-2 border border-slate-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
transition-all duration-200
placeholder:text-slate-400;
}
.input-focused {
@apply ring-2 ring-primary-500 border-transparent bg-primary-50;
}
.card {
@apply bg-white rounded-xl shadow-lg border border-slate-200
overflow-hidden transition-all duration-300;
}
.card-header {
@apply px-4 py-3 border-b border-slate-200 bg-slate-50;
}
.card-body {
@apply p-4;
}
.select {
@apply w-full px-3 py-2 border border-slate-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
bg-white cursor-pointer transition-all duration-200;
}
.tab {
@apply px-4 py-2 text-sm font-medium rounded-t-lg
transition-all duration-200 border-b-2 border-transparent
hover:bg-slate-100 cursor-pointer;
}
.tab-active {
@apply tab bg-white border-primary-500 text-primary-600;
}
.tab-inactive {
@apply tab text-slate-600;
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
}
.badge-warning {
@apply badge bg-warning-100 text-warning-800;
}
.badge-success {
@apply badge bg-success-100 text-success-800;
}
.badge-danger {
@apply badge bg-danger-100 text-danger-800;
}
.label {
@apply block text-sm font-medium text-slate-700 mb-1;
}
}
@layer utilities {
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 3px;
}
}

View File

@@ -1,152 +1,159 @@
import { Env } from "./Env";
import { Env } from './Env';
/**
* Configuration template with placeholder support
*/
export class ConfigTemplate {
public static Empty: ConfigTemplate = new ConfigTemplate();
public static readonly Empty: ConfigTemplate = new ConfigTemplate();
constructor(text: string = "") {
this._contentText = text;
this.extractParams();
private _contentText: string;
private _params: string[];
constructor(contentText: string = '') {
this._contentText = contentText;
this._params = this.extractParams();
}
private _contentText: string = "";
private _params: string[] = [];
public get content(): string {
return this._contentText;
}
/**
* Backward compatibility getter
*/
public get Params(): string[] {
return this.params;
}
public get params(): string[] {
return [...this._params];
}
private extractParams() {
let regex = /@(\w+)@/g;
let matches;
let paramsSet = new Set<string>();
/**
* Extracts @placeholder@ patterns from template content
*/
private extractParams(): string[] {
const regex = /@(\w+)@/g;
const paramsSet = new Set<string>();
let match;
while ((matches = regex.exec(this._contentText)) !== null) {
if (matches.length > 1) {
paramsSet.add(matches[1]);
}
while ((match = regex.exec(this._contentText)) !== null) {
paramsSet.add(match[1]);
}
this._params = Array.from(paramsSet);
return Array.from(paramsSet);
}
}
/**
* Main configuration container
*/
export class Config {
public static get ENV_NAME_PARAM(): string { return "env_name" };
public static readonly ENV_NAME_PARAM = 'env_name';
public envs: Env[] = [];
public template: ConfigTemplate = ConfigTemplate.Empty;
addEnvs(envs: Env[]) {
/**
* Sets environments (backward compatibility)
*/
public addEnvs(envs: Env[]): void {
this.envs = envs;
}
addTemplate(text: string) {
this.template = new ConfigTemplate(text);
/**
* Sets environments
*/
public setEnvs(envs: Env[]): void {
this.envs = envs;
}
getTemplateAsJson(): string {
/**
* Sets template content (backward compatibility)
*/
public addTemplate(content: string): void {
this.setTemplate(content);
}
/**
* Sets template content
*/
public setTemplate(content: string): void {
this.template = new ConfigTemplate(content);
}
/**
* Gets template as JSON string
*/
public getTemplateAsJson(): string {
try {
return this.template.content ;
} catch (error) {
console.error("Error converting template content to JSON:", error);
return "{}";
return this.template.content;
} catch {
return '{}';
}
}
/**
* Updates the template JSON by adding/updating params from the given environment.
* Params are added as "!!! paramName": "@paramName@" placeholder pairs.
* If a param's @placeholder@ already exists in template, it won't be added.
* Existing template content is preserved.
* Updates template by adding placeholders for environment params
*/
updateTemplateFromEnv(env: Env) {
let templateObj: Record<string, any> = {};
public updateTemplateFromEnv(env: Env): void {
let templateObj: Record<string, string> = {};
// Try to parse existing template as JSON
// Try to parse existing template
try {
if (this.template.content.trim()) {
templateObj = JSON.parse(this.template.content);
}
} catch (e) {
// If parsing fails, start with empty object
console.warn("Template is not valid JSON, starting fresh");
} catch {
// Start fresh if invalid JSON
}
// Add/update params from the environment as placeholders
// Add placeholders for params that don't exist yet
for (const param of env.params) {
if (param.name && param.name.trim() !== "") {
const placeholderValue = `@${param.name}@`;
if (param.name && param.name.trim()) {
const placeholder = `@${param.name}@`;
// Check if this placeholder already exists anywhere in the template
const placeholderAlreadyExists = this.template.content.includes(placeholderValue);
if (!placeholderAlreadyExists) {
const placeholderKey = `!!! ${param.name}`;
templateObj[placeholderKey] = placeholderValue;
if (!this.template.content.includes(placeholder)) {
templateObj[`!!! ${param.name}`] = placeholder;
}
}
}
// Convert back to formatted JSON string
const newTemplateContent = JSON.stringify(templateObj, null, 4);
this.template = new ConfigTemplate(newTemplateContent);
}
validateParams(): string[] {
const envKeys = this.envs.map(env => env.params.map(param => param.name)).flat();
const missingParams = this.template.Params.filter(param => param != Config.ENV_NAME_PARAM && !envKeys.includes(param));
if (missingParams.length > 0) {
console.error("Template: missing parameters in environments:", missingParams);
}
return missingParams;
this.template = new ConfigTemplate(JSON.stringify(templateObj, null, 4));
}
/**
* Validates that all @placeholders@ in template have corresponding params.
* Checks DEFAULT env first, then all custom envs.
* Returns array of placeholder names that are not defined.
* Validates that all template placeholders have corresponding params (backward compatibility)
*/
validatePlaceholders(): string[] {
const defaultEnv = this.envs.find(e => e.name === "DEFAULT");
const customEnvs = this.envs.filter(e => e.name !== "DEFAULT");
public validateParams(): string[] {
return this.validatePlaceholders();
}
// Collect all param names from DEFAULT
/**
* Validates that all template placeholders have corresponding params
*/
public validatePlaceholders(): string[] {
const defaultEnv = this.envs.find(e => e.name === 'DEFAULT');
const customEnvs = this.envs.filter(e => e.name !== 'DEFAULT');
// Collect param names from DEFAULT
const defaultParamNames = new Set(
defaultEnv?.params.map(p => p.name).filter(n => n && n.trim() !== "") || []
defaultEnv?.getParamNames() || []
);
// Collect all param names from all custom envs
// Collect param names from all custom envs
const customParamNames = new Set(
customEnvs.flatMap(e => e.params.map(p => p.name).filter(n => n && n.trim() !== ""))
customEnvs.flatMap(e => e.getParamNames())
);
// Extract all @placeholders@ from template
const placeholderRegex = /@(\w+)@/g;
const placeholdersInTemplate = new Set<string>();
let match;
while ((match = placeholderRegex.exec(this.template.content)) !== null) {
placeholdersInTemplate.add(match[1]);
}
// Find placeholders that don't have matching params
// Find missing placeholders
const missingParams: string[] = [];
for (const placeholder of placeholdersInTemplate) {
if (placeholder === Config.ENV_NAME_PARAM) continue; // Skip built-in
for (const placeholder of this.template.params) {
if (placeholder === Config.ENV_NAME_PARAM) continue;
// Check if exists in DEFAULT or in ANY custom env
const inDefault = defaultParamNames.has(placeholder);
const inCustom = customParamNames.has(placeholder);
// Valid if: in DEFAULT, or in at least one custom env
if (!inDefault && !inCustom) {
missingParams.push(placeholder);
}
@@ -154,5 +161,32 @@ export class Config {
return missingParams;
}
}
/**
* Creates a deep copy of the config
*/
public clone(): Config {
const cloned = new Config();
cloned.envs = [...this.envs];
cloned.template = this.template;
return cloned;
}
/**
* Checks if config is empty (no environments or only DEFAULT with no params and empty template)
*/
public isEmpty(): boolean {
if (this.envs.length === 0) {
return true;
}
// Check if only DEFAULT exists with no params
if (this.envs.length === 1 && this.envs[0].name === 'DEFAULT') {
const hasParams = this.envs[0].params.length > 0;
const hasTemplate = this.template.content.trim() && this.template.content !== '{}';
return !hasParams && !hasTemplate;
}
return false;
}
}

View File

@@ -1,135 +1,115 @@
import { Env } from "./Env";
import { EnvParam } from "./EnvParam";
import { Config } from "./Config";
import { Env } from './Env';
import { EnvParam } from './EnvParam';
import { Config } from './Config';
/**
* A utility class for parsing XML configuration files into a structured Config object.
*
* Supports both string-based and file-based parsing, extracting environment definitions
* and their associated parameters. The expected XML format includes:
* - Root element with tag "engine"
* - Child elements "environment" with a "name" attribute
* - Nested "parameter" elements with "name" and "value" attributes
*
* Provides validation and error handling for missing attributes.
* Includes utility method to check if a file is in the supported XML format.
* XML Configuration Parser
* Parses XML files into Config objects
*/
export class ConfigReader {
private readonly rootTag = "engine";
private readonly envTag = "environment";
private readonly envNameAttr = "name";
private readonly paramTag = "parameter";
private readonly paramNameAttr = "name";
private readonly paramValAttr = "value";
private readonly templateTag = "template";
private readonly rootTag = 'engine';
private readonly envTag = 'environment';
private readonly envNameAttr = 'name';
private readonly paramTag = 'parameter';
private readonly paramNameAttr = 'name';
private readonly paramValAttr = 'value';
private readonly templateTag = 'template';
/**
* Parses an XML string into a Config object.
*
* @param xmlString - The XML content as a string
* @param fileType - The MIME type of the XML (default: 'application/xml')
* @returns A Config object containing parsed environments and parameters, or null if parsing fails
* Parses XML string into Config
*/
public parseFromString(xmlString: string, fileType: DOMParserSupportedType = 'application/xml'): Config | null {
let parser = new DOMParser();
let xml = parser.parseFromString(xmlString, fileType);
this.checkTemplate(xml);
let config = new Config();
const parser = new DOMParser();
const xml = parser.parseFromString(xmlString, fileType);
this.validateTemplate(xml);
let envs = this.parseEnvs(xml.querySelectorAll(`${this.rootTag}>${this.envTag}`));
config.addEnvs(envs);
const config = new Config();
const envs = this.parseEnvs(xml.querySelectorAll(`${this.rootTag}>${this.envTag}`));
config.setEnvs(envs);
let tmplElement = xml.getElementsByTagName(this.templateTag)[0];
let tmplText = tmplElement?.textContent?.trim();
const tmplElement = xml.getElementsByTagName(this.templateTag)[0];
const tmplText = tmplElement?.textContent?.trim();
if (!tmplText) {
throw new Error(`Template content is missing or empty in <${this.templateTag}> element.`);
throw new Error(`Template content is missing in <${this.templateTag}> element.`);
}
config.setTemplate(tmplText);
config.addTemplate(tmplText);
console.log("parsed from string res:", config);
return config;
}
/**
* Parses an XML file into a Config object asynchronously.
*
* @param file - The File object representing the XML file
* @returns A Promise resolving to a Config object or null if parsing fails
/**
* Parses XML file into Config
*/
public async parseFromFile(file: File): Promise<Config | null> {
let srcText = await file.text();
const srcText = await file.text();
return this.parseFromString(srcText, file.type as DOMParserSupportedType);
}
private parseEnvs(xmlEnvs: NodeListOf<Element>): Env[] {
let res: Env[] = [];
let i = 0;
for (let xml of xmlEnvs) {
res.push(this.xmlToEnv(xml, i++));
}
return res;
}
private throwError(text: string): string {
throw new Error(text);
}
private xmlToEnv(xml: Element, id: number): Env {
let name = xml.getAttribute(this.envNameAttr) ?? this.throwError(`no attr '${this.envNameAttr}' in '${xml.tagName}'`);
let params = this.parseParams(xml);
return new Env(id, name, params);
}
private parseParams(xml: Element): EnvParam[] {
let paramElements = xml.getElementsByTagName(this.paramTag);
let params: EnvParam[] = [];
let id = 0;
for (let p of paramElements) {
params.push(this.xmlToParam(p, id++));
}
return params;
}
private xmlToParam(xmlParam: Element, id: number): EnvParam {
let name = xmlParam.getAttribute(this.paramNameAttr) ?? this.throwError(`no attr '${this.paramNameAttr}' in '${this.paramTag}'`);
let val = xmlParam.getAttribute(this.paramValAttr) ?? this.throwError(`no attr '${this.paramValAttr}' in '${this.paramTag}'`);
return new EnvParam(id, name, val);
}
/**
* Checks if the given file is in a supported format (text/xml).
*
* @param file - The File object to check
* @returns True if the file is of type 'text/xml', otherwise returns an error message string
* Checks if file format is supported
*/
public static isSupportedFormat(file: File): (boolean | string) {
if (file.type !== "text/xml") {
return `file format ${file.type} not supported (or extension is't .xml)`;
public static isSupportedFormat(file: File): boolean | string {
if (file.type !== 'text/xml') {
return `File format '${file.type}' not supported (expected text/xml)`;
}
return true;
}
public checkTemplate(xml: Document) {
const templateElements = xml.getElementsByTagName(this.templateTag);
/**
* Parses environment elements
*/
private parseEnvs(xmlEnvs: NodeListOf<Element>): Env[] {
return Array.from(xmlEnvs).map((xml, index) => this.xmlToEnv(xml, index));
}
if (templateElements.length === 0) {
this.throwError(`Missing required <${this.templateTag}> element in the XML.`);
/**
* Converts XML element to Env
*/
private xmlToEnv(xml: Element, id: number): Env {
const name = xml.getAttribute(this.envNameAttr);
if (!name) {
throw new Error(`Missing '${this.envNameAttr}' attribute in '${xml.tagName}'`);
}
const params = this.parseParams(xml);
return new Env(id, name, params);
}
/**
* Parses parameter elements
*/
private parseParams(xml: Element): EnvParam[] {
return Array.from(xml.getElementsByTagName(this.paramTag))
.map((p, index) => this.xmlToParam(p, index));
}
/**
* Converts XML element to EnvParam
*/
private xmlToParam(xmlParam: Element, id: number): EnvParam {
const name = xmlParam.getAttribute(this.paramNameAttr);
const value = xmlParam.getAttribute(this.paramValAttr);
if (!name) {
throw new Error(`Missing '${this.paramNameAttr}' attribute in parameter`);
}
if (value === null) {
throw new Error(`Missing '${this.paramValAttr}' attribute in parameter`);
}
if(templateElements.length > 1) {
this.throwError(`Multiple <${this.templateTag}> elements found. Only one is allowed.`);
return new EnvParam(id, name, value);
}
/**
* Validates template element exists
*/
private validateTemplate(xml: Document): void {
const templates = xml.getElementsByTagName(this.templateTag);
if (templates.length === 0) {
throw new Error(`Missing required <${this.templateTag}> element`);
}
if (templates.length > 1) {
throw new Error(`Multiple <${this.templateTag}> elements found. Only one allowed.`);
}
}
}

View File

@@ -1,60 +1,151 @@
import { EnvParam } from "./EnvParam";
import { NamedId } from "./NamedId";
import { EnvParam } from './EnvParam';
import { NamedEntity } from './types';
/**
* Environment configuration containing parameters
*/
export class Env implements NamedEntity {
constructor(
public id?: number,
public name?: string,
public params: EnvParam[] = []
) {}
export class Env implements NamedId {
constructor(
public id?: number,
public name?: string,
public params: EnvParam[] = []
) { }
/**
* Checks if this is the DEFAULT environment
*/
public isDefault(): boolean {
return this.name === 'DEFAULT';
}
public isDefault() {
return this.name === "DEFAULT";
/**
* Adds a new parameter to the environment (backward compatibility)
*/
public addParams(param: EnvParam): Env {
return this.addParam(param);
}
/**
* Adds a new parameter to the environment
*/
public addParam(param: EnvParam): Env {
const newParam = new EnvParam(
param.id ?? this.generateId(),
param.name,
param.value,
param.isChanged
);
return new Env(this.id, this.name, [...this.params, newParam]);
}
/**
* Removes a parameter by ID
*/
public delParam(paramIdOrParam: number | EnvParam): Env {
const paramId = typeof paramIdOrParam === 'number' ? paramIdOrParam : paramIdOrParam.id;
return this.removeParam(paramId!);
}
/**
* Removes a parameter by ID
*/
public removeParam(paramId: number): Env {
return new Env(
this.id,
this.name,
this.params.filter(p => p.id !== paramId)
);
}
/**
* Updates an existing parameter (backward compatibility)
*/
public updateParams(updatedParam: EnvParam): Env {
return this.updateParam(updatedParam);
}
/**
* Updates an existing parameter
*/
public updateParam(updatedParam: EnvParam): Env {
const index = this.params.findIndex(p => p.id === updatedParam.id);
if (index === -1) {
return this;
}
addParams(payload: EnvParam): Env {
payload.id = Math.random() * 10000;
this.params.push(payload);
return new Env(this.id, this.name, [...this.params]);
}
const newParams = [...this.params];
newParams[index] = updatedParam;
return new Env(this.id, this.name, newParams);
}
delParam(param: EnvParam): Env {
let idx = this.params.findIndex(el => el.id === param.id);
if (idx > -1) {
const newP = this.params.filter(el => el.id !== param.id);
return new Env(this.id, this.name, newP);
}
/**
* Gets a parameter by name
*/
public getParamByName(name: string): EnvParam | undefined {
return this.params.find(p => p.name === name);
}
return this;
}
/**
* Gets all parameter names
*/
public getParamNames(): string[] {
return this.params.map(p => p.name).filter((n): n is string => !!n);
}
public updateParams(param: EnvParam): Env {
let idx = this.params.findIndex(el => el.id === param.id);
if (idx > -1) {
let newP = [...this.params];
newP[idx] = param;
return new Env(this.id, this.name, newP);
}
/**
* Creates a copy with updated values
*/
public update(updates: Partial<Env>): Env {
return new Env(
updates.id ?? this.id,
updates.name ?? this.name,
updates.params ?? this.params
);
}
return this;
}
/**
* Generates a unique ID for new parameters
*/
private generateId(): number {
return Date.now() % 100000 + Math.floor(Math.random() * 10000);
}
}
/**
* Base class for environment events (backward compatibility)
*/
export class AppEvent<T> {
protected constructor(public payload: T) { }
constructor(public payload: T) {}
public static add<T>(payload: T): AppEvent<T> {
return new AddEvent(payload);
}
public static del<T>(payload: T): AppEvent<T> {
return new DelEvent(payload);
}
public static update<T>(payload: T): AppEvent<T> {
return new UpdateEvent(payload);
}
public static add<T>(payload: T): AppEvent<T> {
return new AddEvent(payload);
}
public static del<T>(payload: T): AppEvent<T> {
return new RemoveEvent(payload);
}
public static update<T>(payload: T): AppEvent<T> {
return new UpdateEvent(payload);
}
}
export class AddEvent<T> extends AppEvent<T> { }
export class UpdateEvent<T> extends AppEvent<T> { }
export class DelEvent<T> extends AppEvent<T> { }
/**
* Event for adding a parameter
*/
export class AddEvent<T> extends AppEvent<T> {}
/**
* Event for updating a parameter
*/
export class UpdateEvent<T> extends AppEvent<T> {}
/**
* Event for removing a parameter (backward compatibility)
*/
export class DelEvent<T> extends AppEvent<T> {}
/**
* Event for removing a parameter
*/
export class RemoveEvent<T> extends AppEvent<T> {}

View File

@@ -1,32 +1,73 @@
import { NamedId } from "./NamedId";
import { NamedEntity } from './types';
export class EnvParam implements NamedId {
constructor(
public id?: number,
public name?: string,
public value?: string,
public isChanged: boolean = false
) { }
/**
* Environment parameter representing a key-value configuration pair
*/
export class EnvParam implements NamedEntity {
constructor(
public id?: number,
public name?: string,
public value?: string,
public isChanged: boolean = false
) {}
public Changed(v: boolean = true): EnvParam {
return new EnvParam(
this.id,
this.name,
this.value,
v);
}
/**
* Marks the parameter as changed (backward compatibility)
*/
public Changed(changed: boolean = true): EnvParam {
return this.markChanged(changed);
}
public sanitize(v?: string): string {
return v?.replace(/&/g, "&amp")
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
?? "";
}
/**
* Marks the parameter as changed
*/
public markChanged(changed: boolean = true): EnvParam {
return new EnvParam(this.id, this.name, this.value, changed);
}
public humanize(v?: string): string {
return v ?? "";
}
/**
* Creates a copy of this parameter with updated values
*/
public update(updates: Partial<EnvParam>): EnvParam {
return new EnvParam(
updates.id ?? this.id,
updates.name ?? this.name,
updates.value ?? this.value,
updates.isChanged ?? this.isChanged
);
}
/**
* Sanitizes HTML special characters (instance method for backward compatibility)
*/
public sanitize(value?: string): string {
const v = value ?? this.value ?? '';
return EnvParam.sanitize(v);
}
/**
* Sanitizes HTML special characters (static method)
*/
public static sanitize(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Humanizes the value (backward compatibility)
*/
public humanize(value?: string): string {
return value ?? this.value ?? '';
}
/**
* Validates that the parameter has required fields
*/
public isValid(): boolean {
return !!(this.name && this.name.trim() !== '');
}
}

13
src/models/types.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Base interface for entities with optional ID
*/
export interface Entity {
id?: number;
}
/**
* Named entity with optional ID
*/
export interface NamedEntity extends Entity {
name?: string;
}

View File

@@ -36,8 +36,8 @@ const cfgTemplate = `
test("read from a file", async ({expect})=>{
let sut = new ConfigReader();
let file = new File([cfgTemplate],'cfg.json.xml',{type:'application/xml'});
const sut = new ConfigReader();
const file = new File([cfgTemplate],'cfg.json.xml',{type:'application/xml'});
// define a missing jsdom text() function,
// that presents in the real DOM
@@ -46,15 +46,15 @@ test("read from a file", async ({expect})=>{
writable: true
});
let cfg = await sut.parseFromFile(file);
const cfg = await sut.parseFromFile(file);
expect(cfg).not.toBeUndefined();
});
test("load environments and params", ({expect})=>{
let sut = new ConfigReader();
const sut = new ConfigReader();
let cfg = sut.parseFromString(cfgTemplate);
const cfg = sut.parseFromString(cfgTemplate);
expect(cfg?.envs).toHaveLength(3);
expect(cfg?.envs.map(x=>x.name))
@@ -64,9 +64,9 @@ test("load environments and params", ({expect})=>{
});
test("load template", ({expect})=>{
let sut = new ConfigReader();
const sut = new ConfigReader();
let cfg = sut.parseFromString(cfgTemplate);
const cfg = sut.parseFromString(cfgTemplate);
expect(cfg?.template).toBeDefined();
expect(cfg?.template.content.length).toBeGreaterThan(20);

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
describe('JSON Validation with @placeholders@', () => {
/**
* Helper function that mimics the validation logic in ConfigTemplate.tsx
*/
function validateJsonWithPlaceholders(value: string): { valid: boolean; error?: string } {
try {
if (!value.trim()) {
return { valid: true };
}
// This is the current implementation from ConfigTemplate.tsx
// Replace ALL @...@ patterns with "1" (valid for both string and numeric contexts)
const sanitizedValue = value.replace(/@[^@]+@/g, '1');
JSON.parse(sanitizedValue);
return { valid: true };
} catch (e) {
return { valid: false, error: (e as Error).message };
}
}
it('should validate quoted placeholders', () => {
const json = `{
"Host": "@host@",
"Port": "@port@"
}`;
const result = validateJsonWithPlaceholders(json);
expect(result.valid).toBe(true);
});
it('should validate unquoted placeholders', () => {
const json = `{
"Host": "@host@",
"Port": @port@
}`;
const result = validateJsonWithPlaceholders(json);
expect(result.valid).toBe(true);
});
it('should validate mixed quoted and unquoted placeholders', () => {
const json = `{
"Host": "@host@",
"Port": @port@,
"ApiPath": "@host@:@port@/v1/data",
"MessageBroker": {
"hosts": @MessageBrokerHosts@
}
}`;
const result = validateJsonWithPlaceholders(json);
expect(result.valid).toBe(true);
});
it('should validate placeholders inside strings (URLs)', () => {
const json = `{
"ApiUrl": "http://@host@:@port@/api"
}`;
const result = validateJsonWithPlaceholders(json);
expect(result.valid).toBe(true);
});
it('should validate complex real-world template', () => {
const json = `{
"Host": "@host@",
"Port": @port@,
"ApiPath": "@host@:@port@/v1/data",
"MessageBroker": {
"hosts": @MessageBrokerHosts@
},
"basePath": "./@env_name@/in",
"NoParam": "@no_param@"
}`;
const result = validateJsonWithPlaceholders(json);
expect(result.valid).toBe(true);
});
it('should reject invalid JSON structure', () => {
const json = `{
"Host": "@host@",
"Port": @port@
"Missing": "comma"
}`;
const result = validateJsonWithPlaceholders(json);
expect(result.valid).toBe(false);
expect(result.error).toContain('Expected');
});
it('should handle empty value', () => {
const result = validateJsonWithPlaceholders('');
expect(result.valid).toBe(true);
});
it('should handle whitespace only', () => {
const result = validateJsonWithPlaceholders(' \n\t ');
expect(result.valid).toBe(true);
});
});

77
tailwind.config.js Normal file
View File

@@ -0,0 +1,77 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-in': 'slideIn 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}