Merge pull request 'feat' (#6) from feat into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Build Docker Image / build (push) Has been cancelled
Deploy to Server / deploy (push) Failing after 17m35s

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
ssa
2026-02-20 13:58:50 +03:00
13 changed files with 356 additions and 24 deletions

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

View File

@@ -179,6 +179,19 @@ test.describe('Environment Management', () => {
await page.goto('/');
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 },
],
},
},
])
)

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

@@ -8,7 +8,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: LucideIcon;
iconPosition?: 'left' | 'right';
isLoading?: boolean;
}
@@ -33,7 +32,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
variant = 'primary',
size = 'md',
icon: Icon,
iconPosition = 'left',
isLoading = false,
disabled,
children,

View File

@@ -34,7 +34,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
Card.displayName = 'Card';
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {}
type CardHeaderProps = HTMLAttributes<HTMLDivElement>;
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className = '', children, ...props }, ref) => {
@@ -52,7 +52,7 @@ export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
CardHeader.displayName = 'CardHeader';
interface CardBodyProps extends HTMLAttributes<HTMLDivElement> {}
type CardBodyProps = HTMLAttributes<HTMLDivElement>;
export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
({ className = '', children, ...props }, ref) => {
@@ -66,7 +66,7 @@ export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
CardBody.displayName = 'CardBody';
interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {}
type CardFooterProps = HTMLAttributes<HTMLDivElement>;
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
({ className = '', children, ...props }, ref) => {

View File

@@ -60,6 +60,8 @@ export function FileChooser({ onSelected, config }: FileChooserProps) {
URL.revokeObjectURL(url);
}
const hasConfig = !!config && !config.isEmpty();
return (
<div className="bg-white rounded-xl shadow-md p-4 border border-slate-200">
<div className="flex items-center gap-4 flex-wrap">
@@ -89,8 +91,8 @@ export function FileChooser({ onSelected, config }: FileChooserProps) {
onClick={handleDownload}
icon={Download}
size="sm"
disabled={!config}
title="Download full config template"
disabled={!hasConfig}
title={hasConfig ? 'Download full config template' : 'Load or create a config first'}
>
Download
</Button>

View File

@@ -97,7 +97,7 @@ export class Config {
* Updates template by adding placeholders for environment params
*/
public updateTemplateFromEnv(env: Env): void {
let templateObj: Record<string, any> = {};
let templateObj: Record<string, string> = {};
// Try to parse existing template
try {
@@ -171,4 +171,22 @@ export class Config {
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

@@ -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);