diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md new file mode 100644 index 0000000..40b2711 --- /dev/null +++ b/.gitea/workflows/README.md @@ -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://: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) +``` diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5df7d4a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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/ diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..bc54480 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml new file mode 100644 index 0000000..2284bcf --- /dev/null +++ b/.gitea/workflows/docker-build.yml @@ -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 diff --git a/e2e/environment.spec.ts b/e2e/environment.spec.ts index f42f2c7..a8b1885 100644 --- a/e2e/environment.spec.ts +++ b/e2e/environment.spec.ts @@ -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")') diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..332a882 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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 }, + ], + }, }, -]) +) diff --git a/src/builders/EnvBuilder.ts b/src/builders/EnvBuilder.ts index 3bb1efe..98068be 100644 --- a/src/builders/EnvBuilder.ts +++ b/src/builders/EnvBuilder.ts @@ -34,7 +34,7 @@ export class EnvBuilder implements IBuilder { private params(): this { const tag = ``; - 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!") diff --git a/src/builders/index.ts b/src/builders/index.ts index 166618d..351090e 100644 --- a/src/builders/index.ts +++ b/src/builders/index.ts @@ -11,7 +11,7 @@ export interface IBuilder { export class Builder { public static getEnv(env: Env): IBuilder { - let b = new EnvBuilder(); + const b = new EnvBuilder(); b.src = env; return b; }; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index c968482..0f2b4d1 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -8,7 +8,6 @@ interface ButtonProps extends ButtonHTMLAttributes { variant?: ButtonVariant; size?: ButtonSize; icon?: LucideIcon; - iconPosition?: 'left' | 'right'; isLoading?: boolean; } @@ -33,7 +32,6 @@ export const Button = forwardRef( variant = 'primary', size = 'md', icon: Icon, - iconPosition = 'left', isLoading = false, disabled, children, diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index 9d4b9a3..911f413 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -34,7 +34,7 @@ export const Card = forwardRef( Card.displayName = 'Card'; -interface CardHeaderProps extends HTMLAttributes {} +type CardHeaderProps = HTMLAttributes; export const CardHeader = forwardRef( ({ className = '', children, ...props }, ref) => { @@ -52,7 +52,7 @@ export const CardHeader = forwardRef( CardHeader.displayName = 'CardHeader'; -interface CardBodyProps extends HTMLAttributes {} +type CardBodyProps = HTMLAttributes; export const CardBody = forwardRef( ({ className = '', children, ...props }, ref) => { @@ -66,7 +66,7 @@ export const CardBody = forwardRef( CardBody.displayName = 'CardBody'; -interface CardFooterProps extends HTMLAttributes {} +type CardFooterProps = HTMLAttributes; export const CardFooter = forwardRef( ({ className = '', children, ...props }, ref) => { diff --git a/src/componets/FileChooser.tsx b/src/componets/FileChooser.tsx index 00ed77a..9bb1106 100644 --- a/src/componets/FileChooser.tsx +++ b/src/componets/FileChooser.tsx @@ -60,6 +60,8 @@ export function FileChooser({ onSelected, config }: FileChooserProps) { URL.revokeObjectURL(url); } + const hasConfig = !!config && !config.isEmpty(); + return (
@@ -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 diff --git a/src/models/Config.tsx b/src/models/Config.tsx index 1b8ba3a..407c824 100644 --- a/src/models/Config.tsx +++ b/src/models/Config.tsx @@ -97,7 +97,7 @@ export class Config { * Updates template by adding placeholders for environment params */ public updateTemplateFromEnv(env: Env): void { - let templateObj: Record = {}; + let templateObj: Record = {}; // 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; + } } diff --git a/src/test/ConfigReader.test.ts b/src/test/ConfigReader.test.ts index ec782db..c1774c9 100644 --- a/src/test/ConfigReader.test.ts +++ b/src/test/ConfigReader.test.ts @@ -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);