mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
Merge pull request #55 from AnsoCode/fix/alerts-notifications-overhaul
security: pre-release hardening, automated testing & production readiness
This commit is contained in:
@@ -37,3 +37,11 @@ jobs:
|
||||
- name: Build Frontend (Vite/React)
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Run Backend Unit Tests (Vitest)
|
||||
working-directory: ./backend
|
||||
run: npm test
|
||||
|
||||
- name: Lint Frontend (ESLint)
|
||||
working-directory: ./frontend
|
||||
run: npm run lint
|
||||
|
||||
@@ -5,6 +5,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
- **Fixed:** `POST /api/system/console-token` was missing `authMiddleware` — any unauthenticated client could generate console session tokens. Fixed by adding `authMiddleware` to the route.
|
||||
- **Fixed:** Remote node `api_url` was accepted without validation — an attacker could set it to `http://localhost:6379` to SSRF into internal services. Now validates: must be a well-formed `http://` or `https://` URL, and the hostname may not be `localhost`, `127.x.x.x`, `[::1]`, or `0.0.0.0`.
|
||||
- **Fixed:** `env_file` paths in compose.yaml were accepted without boundary checking — absolute paths like `/etc/passwd` could be read/written. All resolved env file paths are now validated to stay within the stack directory.
|
||||
- **Fixed:** Stack name was validated in write routes but not in GET routes (`/api/stacks/:stackName`, `/api/stacks/:stackName/env`, `/api/stacks/:stackName/envs`) — path-traversal names now return 400 on all routes.
|
||||
- **Added:** Rate limiting on `/api/auth/login` and `/api/auth/setup` — 5 attempts per 15-minute window per IP, using `express-rate-limit`.
|
||||
- **Added:** `helmet` middleware for security response headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, etc.).
|
||||
- **Changed:** CORS is now restricted to `FRONTEND_URL` env var in production; development continues to allow any origin.
|
||||
|
||||
### Added
|
||||
- **Added:** `GET /api/health` public endpoint — returns `{ status: "ok", uptime }`. Used by Docker `HEALTHCHECK` directive and external uptime monitors.
|
||||
- **Added:** `HEALTHCHECK` directive in `Dockerfile` — Docker now polls `/api/health` every 30 s and restarts an unhealthy container.
|
||||
- **Added:** Graceful shutdown — backend now listens for `SIGTERM`/`SIGINT`, drains HTTP connections, stops `MonitorService` and `ImageUpdateService`, and closes the SQLite connection before exiting. Prevents data loss when the container is stopped.
|
||||
- **Added:** Automated backend test suite with Vitest — 38 tests covering validation utilities, health endpoint, authentication flows, auth middleware enforcement, console-token security fix, and SSRF validation on node URLs. Run with `cd backend && npm test`.
|
||||
- **Added:** Playwright E2E test scaffolding (`e2e/`) — auth, stack management, and node management spec files with shared login helper. Run with `npm run test:e2e` from the repo root.
|
||||
- **Added:** CI workflow now runs Vitest unit tests and ESLint on every PR.
|
||||
- **Added:** Dockerfile now creates a non-root `sencho` system user and runs the process as that user instead of root.
|
||||
- **Added:** `isValidStackName`, `isValidRemoteUrl`, `isPathWithinBase` extracted to `backend/src/utils/validation.ts` for reuse and testability.
|
||||
|
||||
### Fixed
|
||||
- **Fixed:** ESLint CI step now passes with zero errors — replaced all `any` type annotations with proper types or `unknown` casts, fixed unused catch variables, suppressed `react-refresh/only-export-components` on files that intentionally export both a component and a hook/constant (contexts, badge, button), added file-level `eslint-disable` on animate-ui third-party primitives, and added `eslint-disable-next-line react-hooks/set-state-in-effect` for the LogViewer state reset pattern.
|
||||
- **Fixed:** Four empty `catch {}` blocks in `EditorLayout` (mark-all-read, delete notification, clear-all notifications, image update fetch) now surface errors via `toast.error()` instead of silently swallowing them.
|
||||
- **Fixed:** `ErrorBoundary` component existed but was not connected — it now wraps the root `<App />` in `main.tsx`, catching crashes in any context provider or route component.
|
||||
- **Fixed:** `AlertDialogContent` used `asChild` on `AlertDialogPrimitive.Content` with a `motion.div` wrapper — Radix internally injects a second child (`DescriptionWarning`), causing `React.Children.only` to throw and the ErrorBoundary to trigger whenever the delete-stack confirmation dialog opened. Replaced with CSS keyframe animations (`data-[state=open]:animate-in` / `data-[state=closed]:animate-out`) which don't require `asChild`.
|
||||
- **Fixed:** `WebSocket.Server` replaced with named import `WebSocketServer` from `ws` to fix ESM/CJS interop in test environments.
|
||||
- **Added:** Cross-node notification aggregation — the notification bell now surfaces alerts from all connected remote nodes, not just the local instance. On mount and whenever the node list changes, `EditorLayout` fetches notification history from every registered node in parallel (using `fetchForNode` with targeted `x-node-id` headers). Each remote node also gets a dedicated real-time WebSocket connection (`/ws/notifications?nodeId=`) so alerts push instantly as they fire. Remote-sourced notifications display a node-name badge for quick identification. Mark-as-read, delete, and clear-all actions are routed to the correct node. The backend WS upgrade handler was updated to allow `/ws/notifications?nodeId=<remoteId>` to fall through to the existing proxy path (bare `/ws/notifications` with no nodeId continues to connect locally as before).
|
||||
- **Fixed:** Remote node host console and container exec WebSocket connections now succeed — the gateway exchanges the long-lived `node_proxy` api_token for a short-lived `console_session` JWT (60 s TTL) via a new `POST /api/system/console-token` endpoint before forwarding the WS upgrade to the remote. Previously the remote's `isProxyToken` guard correctly blocked `node_proxy` tokens from interactive terminals, which also blocked legitimate user-initiated console sessions routed through the gateway.
|
||||
- **Fixed:** `StackAlertSheet` now fetches notification agent status from the active node on open and displays a contextual banner: green checkmark with active channel names when agents are enabled, amber warning with a link to Settings → Notifications when none are configured, and a blue info callout on remote nodes explaining that alerts are evaluated and dispatched by that remote Sencho instance.
|
||||
|
||||
+13
@@ -58,8 +58,21 @@ COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
# Set environment to production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create a non-root user and ensure the data/compose directories are writable.
|
||||
# The actual volume paths are mounted at runtime, so we only pre-create the
|
||||
# default data dir here; the compose dir is user-supplied via COMPOSE_DIR.
|
||||
RUN addgroup -S sencho && adduser -S -G sencho sencho \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R sencho:sencho /app
|
||||
|
||||
USER sencho
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check — polls the public /api/health endpoint every 30s
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "const h=require('http');h.get('http://localhost:3000/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
|
||||
|
||||
# Start the server
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
Generated
+1452
-1
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon --watch src --ext ts,json --exec ts-node src/index.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -20,10 +20,13 @@
|
||||
"@types/http-proxy-middleware": "^0.19.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/yaml": "^1.9.6",
|
||||
"nodemon": "^3.1.13",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
@@ -39,6 +42,8 @@
|
||||
"cors": "^2.8.6",
|
||||
"dockerode": "^4.0.9",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"helmet": "^8.1.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Tests for authentication: login, rate limiting, and auth middleware.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
// ─── Login ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
it('returns 200 and sets a cookie on valid credentials', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 401 on wrong password', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: 'wrong-password' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 401 on unknown username', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'nobody', password: 'anything' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 400 when credentials are missing', async () => {
|
||||
const res = await request(app).post('/api/auth/login').send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
it('rejects requests with no token (401)', async () => {
|
||||
const res = await request(app).get('/api/stacks');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects requests with an invalid token (401)', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/stacks')
|
||||
.set('Authorization', 'Bearer this.is.not.valid');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts a valid Bearer token', async () => {
|
||||
// Issue a real token using the known test secret
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.get('/api/stacks')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
// Will succeed (200) or fail with a docker/fs error (500) — but NOT 401
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it('accepts a valid cookie token', async () => {
|
||||
// First login to get the cookie
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
||||
const cookies = loginRes.headers['set-cookie'] as string | string[];
|
||||
const cookieHeader = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/stacks')
|
||||
.set('Cookie', cookieHeader);
|
||||
expect(res.status).not.toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Protected endpoint: console-token ───────────────────────────────────────
|
||||
|
||||
describe('POST /api/system/console-token', () => {
|
||||
it('returns 401 without authentication (was a security bug — C1 fix)', async () => {
|
||||
const res = await request(app).post('/api/system/console-token');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns a token when authenticated', async () => {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Tests for the public /api/health endpoint.
|
||||
* This endpoint must be reachable without authentication.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
// setupTestDb must run before any app import so DATA_DIR is set first
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('GET /api/health', () => {
|
||||
it('returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('returns uptime as a number', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('does not require an auth token', async () => {
|
||||
// No cookie, no Authorization header — must still return 200
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.status).not.toBe(401);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Test DB helper — creates a temporary SQLite database, seeds it with a known
|
||||
* admin credential, and sets process.env so DatabaseService uses it.
|
||||
*
|
||||
* Call this at the top of every test file *before* importing the app,
|
||||
* because DatabaseService initialises its path on first getInstance() call.
|
||||
*/
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const TEST_USERNAME = 'testadmin';
|
||||
export const TEST_PASSWORD = 'testpassword123';
|
||||
export let TEST_JWT_SECRET = '';
|
||||
|
||||
export async function setupTestDb(): Promise<string> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-test-'));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
// Also point COMPOSE_DIR to a temp dir so FileSystemService doesn't fail on missing dir
|
||||
const composeDir = path.join(tmpDir, 'compose');
|
||||
fs.mkdirSync(composeDir, { recursive: true });
|
||||
process.env.COMPOSE_DIR = composeDir;
|
||||
|
||||
// Initialise the DB (singleton will use DATA_DIR we just set)
|
||||
const { DatabaseService } = await import('../../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
// Seed admin credentials
|
||||
const passwordHash = await bcrypt.hash(TEST_PASSWORD, 1); // cost=1 for speed in tests
|
||||
TEST_JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
||||
db.updateGlobalSetting('auth_username', TEST_USERNAME);
|
||||
db.updateGlobalSetting('auth_password_hash', passwordHash);
|
||||
db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET);
|
||||
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
export function cleanupTestDb(tmpDir: string): void {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Tests for node management API — focusing on api_url validation (SSRF fix C2).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('POST /api/nodes — api_url SSRF validation (C2 fix)', () => {
|
||||
it('rejects localhost api_url', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'bad-node', type: 'remote', api_url: 'http://localhost:6379' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/loopback/i);
|
||||
});
|
||||
|
||||
it('rejects 127.0.0.1 api_url', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'bad-node-2', type: 'remote', api_url: 'http://127.0.0.1:5432' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects non-http scheme', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'bad-node-3', type: 'remote', api_url: 'ftp://example.com' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/http/i);
|
||||
});
|
||||
|
||||
it('rejects malformed URL', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'bad-node-4', type: 'remote', api_url: 'not-a-url' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts valid LAN IP', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({
|
||||
name: 'lan-node',
|
||||
type: 'remote',
|
||||
api_url: 'http://192.168.1.50:3000',
|
||||
api_token: 'sometoken',
|
||||
});
|
||||
// Should succeed (201 or 200) — not a validation error
|
||||
expect(res.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('requires api_url for remote nodes', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'missing-url', type: 'remote' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stack name validation on GET routes (H3 fix)', () => {
|
||||
it('rejects path traversal in GET /api/stacks/:stackName', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/stacks/..%2F..%2Fetc%2Fpasswd')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects dots in stack name', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/stacks/.hidden')
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from '../utils/validation';
|
||||
|
||||
// ─── isValidStackName ────────────────────────────────────────────────────────
|
||||
|
||||
describe('isValidStackName', () => {
|
||||
it('accepts alphanumeric names', () => {
|
||||
expect(isValidStackName('mystack')).toBe(true);
|
||||
expect(isValidStackName('MyStack123')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts hyphens and underscores', () => {
|
||||
expect(isValidStackName('my-stack')).toBe(true);
|
||||
expect(isValidStackName('my_stack')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects path separators', () => {
|
||||
expect(isValidStackName('../etc')).toBe(false);
|
||||
expect(isValidStackName('foo/bar')).toBe(false);
|
||||
expect(isValidStackName('foo\\bar')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects dots', () => {
|
||||
expect(isValidStackName('.hidden')).toBe(false);
|
||||
expect(isValidStackName('foo.bar')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects spaces and special characters', () => {
|
||||
expect(isValidStackName('my stack')).toBe(false);
|
||||
expect(isValidStackName('foo;rm -rf /')).toBe(false);
|
||||
expect(isValidStackName('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidRemoteUrl ────────────────────────────────────────────────────────
|
||||
|
||||
describe('isValidRemoteUrl', () => {
|
||||
it('accepts valid http URLs', () => {
|
||||
const result = isValidRemoteUrl('http://192.168.1.10:3000');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid https URLs', () => {
|
||||
const result = isValidRemoteUrl('https://sencho.example.com');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
const result = isValidRemoteUrl('not-a-url');
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
expect(isValidRemoteUrl('ftp://example.com').valid).toBe(false);
|
||||
expect(isValidRemoteUrl('file:///etc/passwd').valid).toBe(false);
|
||||
expect(isValidRemoteUrl('javascript:alert(1)').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects localhost', () => {
|
||||
expect(isValidRemoteUrl('http://localhost:3000').valid).toBe(false);
|
||||
expect(isValidRemoteUrl('http://LOCALHOST:3000').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects loopback IPs', () => {
|
||||
expect(isValidRemoteUrl('http://127.0.0.1:3000').valid).toBe(false);
|
||||
expect(isValidRemoteUrl('http://127.1.2.3').valid).toBe(false);
|
||||
// Node.js URL.hostname preserves brackets: new URL('http://[::1]').hostname === '[::1]'
|
||||
expect(isValidRemoteUrl('http://[::1]:3000').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects 0.0.0.0', () => {
|
||||
expect(isValidRemoteUrl('http://0.0.0.0:3000').valid).toBe(false);
|
||||
});
|
||||
|
||||
it('allows LAN/private IPs (users need these for local network nodes)', () => {
|
||||
// Users legitimately run Sencho nodes on their LAN
|
||||
expect(isValidRemoteUrl('http://192.168.1.100:3000').valid).toBe(true);
|
||||
expect(isValidRemoteUrl('http://10.0.0.5:3000').valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isPathWithinBase ────────────────────────────────────────────────────────
|
||||
|
||||
describe('isPathWithinBase', () => {
|
||||
it('accepts paths within the base directory', () => {
|
||||
expect(isPathWithinBase('/app/compose/mystack/.env', '/app/compose/mystack')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the base directory itself', () => {
|
||||
expect(isPathWithinBase('/app/compose/mystack', '/app/compose/mystack')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects paths that escape via ..', () => {
|
||||
expect(isPathWithinBase('/app/compose/mystack/../../../etc/passwd', '/app/compose/mystack')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects sibling directories', () => {
|
||||
expect(isPathWithinBase('/app/compose/other-stack/.env', '/app/compose/mystack')).toBe(false);
|
||||
});
|
||||
});
|
||||
+109
-22
@@ -1,7 +1,9 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import WebSocket from 'ws';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import DockerController, { globalDockerNetwork } from './services/DockerController';
|
||||
import { FileSystemService } from './services/FileSystemService';
|
||||
@@ -25,6 +27,7 @@ import { ImageUpdateService } from './services/ImageUpdateService';
|
||||
import { templateService } from './services/TemplateService';
|
||||
import { ErrorParser } from './utils/ErrorParser';
|
||||
import { NodeRegistry } from './services/NodeRegistry';
|
||||
import { isValidStackName, isValidRemoteUrl } from './utils/validation';
|
||||
import YAML from 'yaml';
|
||||
import fs, { promises as fsPromises } from 'fs';
|
||||
|
||||
@@ -64,8 +67,20 @@ const getCookieOptions = (req: Request) => ({
|
||||
});
|
||||
|
||||
// Middleware
|
||||
|
||||
// Security headers (X-Frame-Options, X-Content-Type-Options, etc.)
|
||||
// crossOriginEmbedderPolicy is disabled because the Monaco editor uses workers
|
||||
// that don't set the required COEP headers.
|
||||
app.use(helmet({ crossOriginEmbedderPolicy: false }));
|
||||
|
||||
// CORS — in production restrict to the configured frontend origin.
|
||||
// In development, mirror the request origin so Vite's dev server works.
|
||||
const corsOrigin = process.env.NODE_ENV === 'production' && process.env.FRONTEND_URL
|
||||
? process.env.FRONTEND_URL
|
||||
: true;
|
||||
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
origin: corsOrigin,
|
||||
credentials: true,
|
||||
}));
|
||||
// Conditionally parse JSON bodies. Remote proxy requests must NOT have their body
|
||||
@@ -177,6 +192,22 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
}
|
||||
};
|
||||
|
||||
// Rate limiter for auth endpoints — prevents brute-force attacks.
|
||||
// Production: 5 attempts per 15-minute window per IP.
|
||||
// Development: 100 attempts (so E2E tests and local tooling are not blocked).
|
||||
const authRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: process.env.NODE_ENV === 'production' ? 5 : 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
||||
});
|
||||
|
||||
// Public health endpoint - no auth required (used by Docker HEALTHCHECK and uptime monitors)
|
||||
app.get('/api/health', (_req: Request, res: Response): void => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
// Auth Routes (no authentication required)
|
||||
|
||||
// Check if setup is needed
|
||||
@@ -192,7 +223,7 @@ app.get('/api/auth/status', async (req: Request, res: Response): Promise<void> =
|
||||
});
|
||||
|
||||
// Initial setup endpoint
|
||||
app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> => {
|
||||
app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const settings = dbSvc.getGlobalSettings();
|
||||
@@ -243,7 +274,7 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> =
|
||||
});
|
||||
|
||||
// Login endpoint
|
||||
app.post('/api/auth/login', async (req: Request, res: Response): Promise<void> => {
|
||||
app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
@@ -443,7 +474,7 @@ app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
|
||||
const server = http.createServer(app);
|
||||
|
||||
// WebSocket server with authentication
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
let terminalWs: WebSocket | null = null;
|
||||
|
||||
@@ -504,7 +535,7 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
// When a nodeId pointing to a remote node is provided, fall through to the
|
||||
// proxy block below so the browser subscribes to that remote node's push stream.
|
||||
if (pathname === '/ws/notifications' && (!node || node.type !== 'remote')) {
|
||||
const notifWss = new WebSocket.Server({ noServer: true });
|
||||
const notifWss = new WebSocketServer({ noServer: true });
|
||||
notifWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
notifWss.close();
|
||||
notificationSubscribers.add(ws);
|
||||
@@ -568,7 +599,7 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
|
||||
if (logsMatch) {
|
||||
// Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs
|
||||
const logsWss = new WebSocket.Server({ noServer: true });
|
||||
const logsWss = new WebSocketServer({ noServer: true });
|
||||
logsWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
// Close the per-connection server immediately after the upgrade is complete.
|
||||
// The wss instance is only needed to negotiate the handshake; keeping it open
|
||||
@@ -591,7 +622,7 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const hostConsoleWss = new WebSocket.Server({ noServer: true });
|
||||
const hostConsoleWss = new WebSocketServer({ noServer: true });
|
||||
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hostConsoleWss.close();
|
||||
let targetDirectory = '';
|
||||
@@ -708,6 +739,9 @@ app.get('/api/stacks', async (req: Request, res: Response) => {
|
||||
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
@@ -768,20 +802,22 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
|
||||
const service = parsed.services[serviceName];
|
||||
if (!service?.env_file) continue;
|
||||
|
||||
const addEnvPath = (rawPath: string) => {
|
||||
const resolved = path.resolve(stackDir, rawPath);
|
||||
// Reject paths that escape the stack directory
|
||||
if (!resolved.startsWith(path.resolve(stackDir) + path.sep) && resolved !== path.resolve(stackDir)) {
|
||||
console.warn(`[Security] env_file path "${rawPath}" escapes stack directory — skipping`);
|
||||
return;
|
||||
}
|
||||
envFiles.add(resolved);
|
||||
};
|
||||
|
||||
if (typeof service.env_file === 'string') {
|
||||
const resolvedPath = path.isAbsolute(service.env_file)
|
||||
? service.env_file
|
||||
: path.resolve(stackDir, service.env_file);
|
||||
envFiles.add(resolvedPath);
|
||||
addEnvPath(service.env_file);
|
||||
} else if (Array.isArray(service.env_file)) {
|
||||
for (const entry of service.env_file) {
|
||||
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
|
||||
if (entryPath) {
|
||||
const resolvedPath = path.isAbsolute(entryPath)
|
||||
? entryPath
|
||||
: path.resolve(stackDir, entryPath);
|
||||
envFiles.add(resolvedPath);
|
||||
}
|
||||
if (entryPath) addEnvPath(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,6 +837,9 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
|
||||
app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
res.json({ envFiles: envPaths });
|
||||
} catch (error) {
|
||||
@@ -811,6 +850,9 @@ app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
|
||||
app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const requestedFile = req.query.file as string | undefined;
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
|
||||
@@ -1539,7 +1581,7 @@ app.post('/api/notifications/test', async (req: Request, res: Response) => {
|
||||
// to a remote node, it calls this endpoint (authenticated with the long-lived api_token)
|
||||
// to receive a short-lived token. The remote's WS upgrade handler allows 'console_session'
|
||||
// tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths.
|
||||
app.post('/api/system/console-token', (req: Request, res: Response): void => {
|
||||
app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
@@ -1801,6 +1843,7 @@ app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Resp
|
||||
// Node Management API
|
||||
// =========================
|
||||
|
||||
|
||||
// List all nodes
|
||||
app.get('/api/nodes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -1836,8 +1879,14 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
|
||||
if (!type || !['local', 'remote'].includes(type)) {
|
||||
return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
|
||||
}
|
||||
if (type === 'remote' && (!api_url || typeof api_url !== 'string')) {
|
||||
return res.status(400).json({ error: 'API URL is required for remote nodes' });
|
||||
if (type === 'remote') {
|
||||
if (!api_url || typeof api_url !== 'string') {
|
||||
return res.status(400).json({ error: 'API URL is required for remote nodes' });
|
||||
}
|
||||
const urlCheck = isValidRemoteUrl(api_url);
|
||||
if (!urlCheck.valid) {
|
||||
return res.status(400).json({ error: urlCheck.reason });
|
||||
}
|
||||
}
|
||||
|
||||
const id = DatabaseService.getInstance().addNode({
|
||||
@@ -1865,6 +1914,13 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const updates = req.body;
|
||||
|
||||
if (updates.api_url !== undefined && updates.api_url !== '') {
|
||||
const urlCheck = isValidRemoteUrl(updates.api_url);
|
||||
if (!urlCheck.valid) {
|
||||
return res.status(400).json({ error: urlCheck.reason });
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().updateNode(id, updates);
|
||||
|
||||
// Evict cached Docker connection so it reconnects with new config
|
||||
@@ -1948,4 +2004,35 @@ async function startServer() {
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
// Only start the server when this file is the entry point (not when imported by tests).
|
||||
if (require.main === module) {
|
||||
startServer();
|
||||
}
|
||||
|
||||
// Exports used by tests (supertest requires the http.Server instance).
|
||||
export { app, server };
|
||||
|
||||
// Graceful shutdown — allows in-flight requests to finish, then cleanly stops
|
||||
// background services and closes the SQLite connection before the process exits.
|
||||
// Docker sends SIGTERM when the container stops; Ctrl-C sends SIGINT in dev.
|
||||
const gracefulShutdown = (signal: string) => {
|
||||
console.log(`[Shutdown] ${signal} received — shutting down gracefully…`);
|
||||
|
||||
server.close(() => {
|
||||
console.log('[Shutdown] HTTP server closed');
|
||||
try { MonitorService.getInstance().stop(); } catch { /* already stopped */ }
|
||||
try { ImageUpdateService.getInstance().stop(); } catch { /* already stopped */ }
|
||||
try { DatabaseService.getInstance().getDb().close(); } catch { /* already closed */ }
|
||||
console.log('[Shutdown] Done — exiting');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force-exit after 10 s if connections refuse to drain
|
||||
setTimeout(() => {
|
||||
console.error('[Shutdown] Timed out waiting for connections — forcing exit');
|
||||
process.exit(1);
|
||||
}, 10_000).unref();
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Stack name must only contain URL-safe characters with no path separators.
|
||||
* Prevents path-traversal attacks when the name is used to build filesystem paths.
|
||||
*/
|
||||
export const isValidStackName = (name: string): boolean =>
|
||||
/^[a-zA-Z0-9_-]+$/.test(name);
|
||||
|
||||
/**
|
||||
* Validates that a remote node API URL is a safe, well-formed HTTP/HTTPS URL.
|
||||
* Rejects loopback addresses to prevent SSRF against local services.
|
||||
* Private/LAN IPs are allowed — users legitimately point Sencho at nodes on their LAN.
|
||||
*/
|
||||
export function isValidRemoteUrl(
|
||||
raw: string,
|
||||
): { valid: true; url: URL } | { valid: false; reason: string } {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'API URL must be a valid URL (e.g. https://my-server.example.com:3000)',
|
||||
};
|
||||
}
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return { valid: false, reason: 'API URL must use http:// or https://' };
|
||||
}
|
||||
// Node.js URL API preserves brackets for IPv6: new URL('http://[::1]').hostname === '[::1]'
|
||||
const loopback = /^(localhost|127(\.\d+){3}|\[::1\]|0\.0\.0\.0)$/i;
|
||||
if (loopback.test(url.hostname)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'API URL cannot point to localhost or loopback — use the actual host address',
|
||||
};
|
||||
}
|
||||
return { valid: true, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a resolved file path stays within a given base directory.
|
||||
* Returns true if the path is safe, false if it escapes the base.
|
||||
*/
|
||||
export function isPathWithinBase(resolvedPath: string, baseDir: string): boolean {
|
||||
const normalizedBase = path.resolve(baseDir);
|
||||
const normalizedPath = path.resolve(resolvedPath);
|
||||
return (
|
||||
normalizedPath === normalizedBase ||
|
||||
normalizedPath.startsWith(normalizedBase + path.sep)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
// Only run TypeScript sources — exclude the compiled dist/ output.
|
||||
include: ['src/__tests__/**/*.test.ts'],
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
// Each test file gets its own worker so singletons are fresh between files.
|
||||
pool: 'forks',
|
||||
// Timeout generous for DB init and HTTP calls.
|
||||
testTimeout: 15_000,
|
||||
// Sequential within each file (DB state is shared per file).
|
||||
sequence: { concurrent: false },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Authentication E2E tests.
|
||||
* Tests login, logout, and unauthenticated redirect.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, isDashboard, TEST_USERNAME, TEST_PASSWORD } from './helpers';
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test('login with valid credentials shows the dashboard', async ({ page }) => {
|
||||
await loginAs(page, TEST_USERNAME, TEST_PASSWORD);
|
||||
expect(await isDashboard(page)).toBe(true);
|
||||
// URL should not be on a login page
|
||||
expect(page.url()).not.toMatch(/login/i);
|
||||
});
|
||||
|
||||
test('login with wrong password shows an error', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Skip if already logged in
|
||||
if (await isDashboard(page)) {
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
await page.locator('#username').fill(TEST_USERNAME);
|
||||
await page.locator('#password').fill('definitely-wrong-password-xyz');
|
||||
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
|
||||
|
||||
// Should show error message, not navigate to dashboard
|
||||
await expect(page.locator('text=/invalid|incorrect|wrong|failed/i')).toBeVisible({ timeout: 5_000 });
|
||||
expect(await isDashboard(page)).toBe(false);
|
||||
});
|
||||
|
||||
test('visiting the app without auth redirects to login', async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
// Should be on login or setup, not dashboard
|
||||
expect(await isDashboard(page)).toBe(false);
|
||||
// Login button or setup form should be visible
|
||||
const loginOrSetup = await page.locator(
|
||||
'button:has-text("Login"), button:has-text("Sign in"), button[type="submit"]'
|
||||
).first().isVisible();
|
||||
expect(loginOrSetup).toBe(true);
|
||||
});
|
||||
|
||||
test('logout returns to the login screen', async ({ page }) => {
|
||||
await loginAs(page);
|
||||
// The logout button renders a Lucide LogOut icon — Lucide adds a CSS class matching the icon name
|
||||
const logoutBtn = page.locator('button:has(.lucide-log-out)');
|
||||
await logoutBtn.click();
|
||||
await page.waitForTimeout(1_000);
|
||||
expect(await isDashboard(page)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shared helpers for Sencho E2E tests.
|
||||
*
|
||||
* CREDENTIALS: Set E2E_USERNAME and E2E_PASSWORD env vars to match
|
||||
* your dev instance's admin account. Defaults assume the initial setup
|
||||
* was completed with username "admin" and password "password123".
|
||||
*
|
||||
* E2E_USERNAME=admin E2E_PASSWORD=mypassword npx playwright test
|
||||
*/
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export const TEST_USERNAME = process.env.E2E_USERNAME ?? 'admin';
|
||||
export const TEST_PASSWORD = process.env.E2E_PASSWORD ?? 'password123';
|
||||
|
||||
/** Selector for the dashboard — only present in EditorLayout, not on login/setup pages */
|
||||
const DASHBOARD_INDICATOR = 'img[alt="Sencho Logo"]';
|
||||
|
||||
/** Returns true if the current page is the first-run setup screen */
|
||||
async function isSetupPage(page: Page): Promise<boolean> {
|
||||
return page.locator('#confirmPassword, input[placeholder*="Confirm"]').isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/** Returns true if the current page is the login screen */
|
||||
async function isLoginPage(page: Page): Promise<boolean> {
|
||||
return page.locator('button:has-text("Login"), button:has-text("Sign in")').isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/** Returns true if the dashboard (EditorLayout) is loaded */
|
||||
export async function isDashboard(page: Page): Promise<boolean> {
|
||||
return page.locator(DASHBOARD_INDICATOR).isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the app root, complete first-run setup if needed, then log in.
|
||||
* After this call the dashboard is guaranteed to be visible.
|
||||
*/
|
||||
export async function loginAs(page: Page, username = TEST_USERNAME, password = TEST_PASSWORD) {
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for the app to finish its auth check (loading spinner disappears)
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// ── First-run setup ───────────────────────────────────────────────────────
|
||||
if (await isSetupPage(page)) {
|
||||
await page.locator('#username').fill(username);
|
||||
await page.locator('#password').fill(password);
|
||||
const confirmInput = page.locator('#confirmPassword');
|
||||
if (await confirmInput.isVisible()) await confirmInput.fill(password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
// After setup, the app logs in automatically and shows the dashboard
|
||||
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Login screen ─────────────────────────────────────────────────────────
|
||||
if (await isLoginPage(page)) {
|
||||
await page.locator('#username').fill(username);
|
||||
await page.locator('#password').fill(password);
|
||||
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
|
||||
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Already on the dashboard ──────────────────────────────────────────────
|
||||
if (await isDashboard(page)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'loginAs: could not determine page state — expected setup, login, or dashboard. ' +
|
||||
'Check that E2E_USERNAME and E2E_PASSWORD are set correctly.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Node management E2E tests.
|
||||
* Tests the SSRF validation we added (C2 fix) is surfaced in the UI.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs } from './helpers';
|
||||
|
||||
test.describe('Node management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page);
|
||||
// Navigate to the nodes section (Settings / Node Manager)
|
||||
const nodesBtn = page.getByRole('button', { name: /nodes|manage nodes|settings/i }).first();
|
||||
if (await nodesBtn.isVisible()) await nodesBtn.click();
|
||||
});
|
||||
|
||||
test('adding a node with localhost api_url shows a validation error', async ({ page }) => {
|
||||
// Open "add node" dialog
|
||||
const addBtn = page.getByRole('button', { name: /add node|new node|\+/i });
|
||||
if (!await addBtn.isVisible()) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await addBtn.click();
|
||||
|
||||
await page.getByLabel(/node name/i).fill('bad-node');
|
||||
// Select "remote" type if there's a type selector
|
||||
const typeSelect = page.getByLabel(/type/i);
|
||||
if (await typeSelect.isVisible()) await typeSelect.selectOption('remote');
|
||||
|
||||
await page.getByLabel(/api url/i).fill('http://localhost:6379');
|
||||
await page.getByRole('button', { name: /add|save|create/i }).click();
|
||||
|
||||
// Should see an error about loopback/localhost
|
||||
await expect(page.getByText(/loopback|localhost/i)).toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
|
||||
test('adding a node with an invalid URL shows an error', async ({ page }) => {
|
||||
const addBtn = page.getByRole('button', { name: /add node|new node|\+/i });
|
||||
if (!await addBtn.isVisible()) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await addBtn.click();
|
||||
|
||||
await page.getByLabel(/node name/i).fill('bad-url-node');
|
||||
const typeSelect = page.getByLabel(/type/i);
|
||||
if (await typeSelect.isVisible()) await typeSelect.selectOption('remote');
|
||||
|
||||
await page.getByLabel(/api url/i).fill('not-a-url-at-all');
|
||||
await page.getByRole('button', { name: /add|save|create/i }).click();
|
||||
|
||||
await expect(page.getByText(/valid url|invalid url|url/i)).toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Stack management E2E tests — happy path CRUD.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, TEST_USERNAME, TEST_PASSWORD } from './helpers';
|
||||
|
||||
const TEST_STACK = 'e2e-test-stack';
|
||||
|
||||
/** Wait for stacks to load in the sidebar (uses /api/stacks via the browser context). */
|
||||
async function waitForStacksLoaded(page: import('@playwright/test').Page) {
|
||||
// Poll until the stacks API returns data AND the sidebar has at least one item
|
||||
await page.waitForFunction(() => {
|
||||
const items = document.querySelectorAll('[cmdk-item]');
|
||||
return items.length > 0;
|
||||
}, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Delete the test stack via the browser's authenticated fetch (so cookies are included). */
|
||||
async function deleteTestStackViaApi(page: import('@playwright/test').Page) {
|
||||
await page.evaluate(async (name) => {
|
||||
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
|
||||
}, TEST_STACK);
|
||||
}
|
||||
|
||||
test.describe('Stack management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
});
|
||||
|
||||
test('create a new stack', async ({ page }) => {
|
||||
// Remove leftover from prior runs (using browser context auth)
|
||||
await deleteTestStackViaApi(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Reload to get a fresh sidebar without the deleted stack
|
||||
await page.reload();
|
||||
await loginAs(page); // may re-login if cookie expired, otherwise skips to dashboard
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Create Stack' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await page.locator('#create-stack-name').fill(TEST_STACK);
|
||||
await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// Wait for dialog to close (success) or error message to appear (failure)
|
||||
await Promise.race([
|
||||
page.getByRole('dialog').waitFor({ state: 'hidden', timeout: 8_000 }),
|
||||
page.getByText(/already exists/i).waitFor({ state: 'visible', timeout: 8_000 }),
|
||||
]).catch(() => {});
|
||||
|
||||
// The stack should now exist — refresh and verify via the sidebar
|
||||
await page.reload();
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
await expect(page.getByText(TEST_STACK).first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('delete the test stack', async ({ page }) => {
|
||||
// Confirm the stack exists in the sidebar
|
||||
await expect(page.getByText(TEST_STACK).first()).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click on the stack to open the editor
|
||||
await page.getByText(TEST_STACK).first().click();
|
||||
|
||||
// The toolbar Delete button has the Lucide Trash2 icon
|
||||
const deleteBtn = page.locator('button:has(.lucide-trash-2)');
|
||||
await expect(deleteBtn).toBeVisible({ timeout: 10_000 });
|
||||
await deleteBtn.click();
|
||||
|
||||
// AlertDialog confirmation
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Stack should no longer appear in the sidebar (exact match to avoid false positives from
|
||||
// similarly-named stacks; scoped to the CommandList)
|
||||
await expect(
|
||||
page.locator('[role="listbox"]').getByText(TEST_STACK, { exact: true })
|
||||
).not.toBeVisible({ timeout: 8_000 });
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,11 @@ export interface TemplateEnv {
|
||||
default?: string;
|
||||
}
|
||||
|
||||
interface TemplateVolume {
|
||||
container?: string;
|
||||
bind?: string;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
type?: number;
|
||||
title: string;
|
||||
@@ -24,7 +29,7 @@ export interface Template {
|
||||
logo?: string;
|
||||
image?: string;
|
||||
ports?: string[];
|
||||
volumes?: any[];
|
||||
volumes?: TemplateVolume[];
|
||||
env?: TemplateEnv[];
|
||||
categories?: string[];
|
||||
github_url?: string;
|
||||
@@ -68,8 +73,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
if (!res.ok) throw new Error('Failed to fetch templates');
|
||||
const data = await res.json();
|
||||
setTemplates(data || []);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to load App Shop");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || "Failed to load App Shop");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -95,7 +100,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
|
||||
// Initialize Volumes
|
||||
const initVols: Record<string, string> = {};
|
||||
t.volumes?.forEach((v: any) => {
|
||||
t.volumes?.forEach((v) => {
|
||||
if (v.container) {
|
||||
initVols[v.container] = v.bind || `./${v.container.split('/').filter(Boolean).pop() || 'data'}`;
|
||||
}
|
||||
@@ -146,7 +151,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
|
||||
// Process Volumes
|
||||
if (modifiedTemplate.volumes) {
|
||||
modifiedTemplate.volumes = modifiedTemplate.volumes.map((v: any) => {
|
||||
modifiedTemplate.volumes = modifiedTemplate.volumes.map((v) => {
|
||||
if (v.container && volVars[v.container] !== undefined) {
|
||||
return { ...v, bind: volVars[v.container] };
|
||||
}
|
||||
@@ -175,8 +180,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
toast.success(`${selectedTemplate?.title} deployed successfully!`);
|
||||
setIsSheetOpen(false);
|
||||
onDeploySuccess(stackName.trim());
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Deployment failed');
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || 'Deployment failed');
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
@@ -399,14 +404,15 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
{selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Volumes (Host : Container)</h4>
|
||||
{selectedTemplate.volumes.map((v: any, idx: number) => {
|
||||
if (!v.container) return null;
|
||||
{selectedTemplate.volumes.map((v, idx: number) => {
|
||||
const containerPath = v.container;
|
||||
if (!containerPath) return null;
|
||||
return (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-mono">Container: {v.container}</Label>
|
||||
<Label className="text-xs text-muted-foreground font-mono">Container: {containerPath}</Label>
|
||||
<Input
|
||||
value={volVars[v.container] !== undefined ? volVars[v.container] : ''}
|
||||
onChange={(e) => setVolVars(prev => ({ ...prev, [v.container]: e.target.value }))}
|
||||
value={volVars[containerPath] !== undefined ? volVars[containerPath] : ''}
|
||||
onChange={(e) => setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))}
|
||||
placeholder={`/path/to/host/dir`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
type TerminalContainer = HTMLDivElement & { __resizeObserver?: ResizeObserver };
|
||||
|
||||
interface BashExecModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -191,14 +193,15 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
|
||||
resizeObserver.observe(containerEl);
|
||||
|
||||
// Store observer cleanup on the container element for later
|
||||
(containerEl as any).__resizeObserver = resizeObserver;
|
||||
(containerEl as TerminalContainer).__resizeObserver = resizeObserver;
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up ResizeObserver
|
||||
if (terminalRef.current && (terminalRef.current as any).__resizeObserver) {
|
||||
(terminalRef.current as any).__resizeObserver.disconnect();
|
||||
delete (terminalRef.current as any).__resizeObserver;
|
||||
const el = terminalRef.current as TerminalContainer | null;
|
||||
if (el?.__resizeObserver) {
|
||||
el.__resizeObserver.disconnect();
|
||||
delete el.__resizeObserver;
|
||||
}
|
||||
};
|
||||
}, [isOpen, containerId, cleanup]);
|
||||
|
||||
@@ -410,7 +410,9 @@ export default function EditorLayout() {
|
||||
const data = await res.json();
|
||||
setStackUpdates(data);
|
||||
}
|
||||
} catch (e) { }
|
||||
} catch (e: unknown) {
|
||||
console.error('[ImageUpdates] fetch failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
@@ -423,7 +425,10 @@ export default function EditorLayout() {
|
||||
: fetchForNode('/notifications/read', nodeId, { method: 'POST' })
|
||||
));
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: 1 })));
|
||||
} catch (e) { }
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to mark notifications as read');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotification = async (notif: Notification) => {
|
||||
@@ -435,7 +440,10 @@ export default function EditorLayout() {
|
||||
await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' });
|
||||
}
|
||||
setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId)));
|
||||
} catch (e) { }
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to delete notification');
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllNotifications = async () => {
|
||||
@@ -448,7 +456,10 @@ export default function EditorLayout() {
|
||||
: fetchForNode('/notifications', nodeId, { method: 'DELETE' })
|
||||
));
|
||||
setNotifications([]);
|
||||
} catch (e) { }
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; error?: string };
|
||||
toast.error(err?.message || err?.error || 'Failed to clear notifications');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -481,7 +492,7 @@ export default function EditorLayout() {
|
||||
let currentRx = 0;
|
||||
let currentTx = 0;
|
||||
if (data.networks) {
|
||||
Object.values(data.networks).forEach((net: any) => {
|
||||
Object.values(data.networks as Record<string, { rx_bytes?: number; tx_bytes?: number }>).forEach((net) => {
|
||||
currentRx += net.rx_bytes || 0;
|
||||
currentTx += net.tx_bytes || 0;
|
||||
});
|
||||
@@ -702,9 +713,9 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
toast.error(error.message || 'Failed to deploy stack');
|
||||
toast.error((error as Error).message || 'Failed to deploy stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -730,9 +741,9 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to stop:', error);
|
||||
toast.error(error.message || 'Failed to stop stack');
|
||||
toast.error((error as Error).message || 'Failed to stop stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -758,9 +769,9 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to restart:', error);
|
||||
toast.error(error.message || 'Failed to restart stack');
|
||||
toast.error((error as Error).message || 'Failed to restart stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -786,9 +797,9 @@ export default function EditorLayout() {
|
||||
const conts = await containersRes.json();
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
await refreshStacks(true);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to update:', error);
|
||||
toast.error(error.message || 'Failed to update stack');
|
||||
toast.error((error as Error).message || 'Failed to update stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -819,9 +830,9 @@ export default function EditorLayout() {
|
||||
setIsEditing(false);
|
||||
}
|
||||
await refreshStacks();
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to delete stack:', error);
|
||||
toast.error(error.message || 'Failed to delete stack');
|
||||
toast.error((error as Error).message || 'Failed to delete stack');
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
@@ -849,9 +860,9 @@ export default function EditorLayout() {
|
||||
await refreshStacks();
|
||||
// Auto-load the new stack in the editor pane
|
||||
await loadFile(stackName);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to create stack:', error);
|
||||
toast.error(error.message || 'Failed to create stack');
|
||||
toast.error((error as Error).message || 'Failed to create stack');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export function GlobalObservabilityView() {
|
||||
const entry: LogEntry = JSON.parse(event.data);
|
||||
entry._id = ++logIdRef.current;
|
||||
bufferRef.current.push(entry);
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
@@ -243,7 +243,7 @@ export function GlobalObservabilityView() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Select value={streamFilter} onValueChange={(val: any) => setStreamFilter(val)}>
|
||||
<Select value={streamFilter} onValueChange={(val) => setStreamFilter(val as 'ALL' | 'STDOUT' | 'STDERR')}>
|
||||
<SelectTrigger className="w-[110px] h-8 text-sm">
|
||||
<SelectValue placeholder="Stream" />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -18,6 +18,12 @@ interface Stats {
|
||||
inactive: number;
|
||||
}
|
||||
|
||||
interface MetricPoint {
|
||||
timestamp: number;
|
||||
cpu_percent: number;
|
||||
memory_mb: number;
|
||||
}
|
||||
|
||||
interface SystemStats {
|
||||
cpu: {
|
||||
usage: string;
|
||||
@@ -62,7 +68,7 @@ export default function HomeDashboard() {
|
||||
const [newStackName, setNewStackName] = useState('');
|
||||
const [stats, setStats] = useState<Stats>({ active: 0, exited: 0, total: 0, inactive: 0 });
|
||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||||
const [metrics, setMetrics] = useState<any[]>([]);
|
||||
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
|
||||
|
||||
// Fetch container stats - re-runs when active node changes so stale data is cleared immediately
|
||||
useEffect(() => {
|
||||
@@ -194,9 +200,9 @@ export default function HomeDashboard() {
|
||||
setConvertedYaml('');
|
||||
setDockerRunInput('');
|
||||
window.location.reload(); // Refresh to show new stack
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to create stack:', error);
|
||||
toast.error(error?.message || error?.error || 'Failed to create stack');
|
||||
toast.error((error as Error).message || 'Failed to create stack');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export function LogViewer({ containerId, containerName, isOpen, onClose }: LogVi
|
||||
useEffect(() => {
|
||||
if (!isOpen || !containerId) return;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLogs([]);
|
||||
setIsConnected(false);
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function NodeManager() {
|
||||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||||
const [deletingNode, setDeletingNode] = useState<Node | null>(null);
|
||||
const [testing, setTesting] = useState<number | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ nodeId: number; info: any } | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ nodeId: number; info: { serverVersion?: string; os?: string; architecture?: string; containers?: number; images?: number; cpus?: number } } | null>(null);
|
||||
|
||||
// Node token generation state
|
||||
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||
@@ -84,8 +84,8 @@ export function NodeManager() {
|
||||
}
|
||||
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to create node');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to create node');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,8 +105,8 @@ export function NodeManager() {
|
||||
setEditingNodeId(null);
|
||||
setFormData(defaultFormData);
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to update node');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to update node');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,8 +135,8 @@ export function NodeManager() {
|
||||
setDeleteOpen(false);
|
||||
setDeletingNode(null);
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete node');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to delete node');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,8 +153,8 @@ export function NodeManager() {
|
||||
toast.error(`Failed to connect: ${result.error}`);
|
||||
}
|
||||
await refreshNodes();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Connection test failed');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Connection test failed');
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
@@ -169,8 +169,8 @@ export function NodeManager() {
|
||||
const { token } = await res.json();
|
||||
setGeneratedToken(token);
|
||||
toast.success('Node token generated');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to generate token');
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to generate token');
|
||||
} finally {
|
||||
setGeneratingToken(false);
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ export default function ResourcesView() {
|
||||
setNetworks(await networksRes.json());
|
||||
setOrphans(await orphansRes.json());
|
||||
setSelectedOrphans([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data', error);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data', err);
|
||||
toast.error('Failed to load resources data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -105,7 +105,7 @@ export default function ResourcesView() {
|
||||
toast.success(`Pruned ${confirmPruneType}.`);
|
||||
}
|
||||
await fetchAllData();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(`Failed to prune ${confirmPruneType}`);
|
||||
} finally {
|
||||
setIsActioning(false);
|
||||
@@ -124,7 +124,7 @@ export default function ResourcesView() {
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success(`Deleted ${confirmDelete.type.slice(0, -1)}`);
|
||||
await fetchAllData();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(`Failed to delete ${confirmDelete.type.slice(0, -1)}`);
|
||||
} finally {
|
||||
setIsActioning(false);
|
||||
@@ -154,7 +154,7 @@ export default function ResourcesView() {
|
||||
toast.success(`Purged ${selectedOrphans.length} ghost container(s)`);
|
||||
setBulkPurgeConfirm(false);
|
||||
await fetchAllData();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to purge selected containers.');
|
||||
} finally {
|
||||
setIsActioning(false);
|
||||
@@ -169,7 +169,7 @@ export default function ResourcesView() {
|
||||
|
||||
const totalReclaimable = chartData.reduce((acc, curr) => acc + curr.value, 0);
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number }> }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-popover text-popover-foreground border rounded-lg shadow-md p-3 text-sm">
|
||||
|
||||
@@ -147,7 +147,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Failed to delete alert rule.');
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
toast.error('Network error. Could not reach the node.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -204,4 +205,4 @@ export {
|
||||
type DialogDescriptionProps,
|
||||
type DialogContextType,
|
||||
type DialogFlipDirection,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -560,4 +561,4 @@ export {
|
||||
type DropdownMenuRadioGroupProps,
|
||||
type DropdownMenuContextType,
|
||||
type DropdownMenuSubContextType,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -204,4 +205,4 @@ export {
|
||||
type HoverCardContentProps,
|
||||
type HoverCardArrowProps,
|
||||
type HoverCardContextType,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -159,4 +160,4 @@ export {
|
||||
type PopoverCloseProps,
|
||||
type PopoverArrowProps,
|
||||
type PopoverContextType,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -188,4 +189,4 @@ export {
|
||||
type SheetFooterProps,
|
||||
type SheetTitleProps,
|
||||
type SheetDescriptionProps,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -152,4 +153,4 @@ export {
|
||||
type SwitchIconProps,
|
||||
type SwitchIconPosition,
|
||||
type SwitchContextType,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -217,4 +218,4 @@ export {
|
||||
type TooltipContentProps,
|
||||
type TooltipArrowProps,
|
||||
type TooltipContextType,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable */
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -350,4 +351,4 @@ function SlidingNumber({
|
||||
);
|
||||
}
|
||||
|
||||
export { SlidingNumber, type SlidingNumberProps };
|
||||
export { SlidingNumber, type SlidingNumberProps };
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
@@ -30,18 +29,16 @@ const AlertDialogContent = React.forwardRef<
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content ref={ref} asChild {...props}>
|
||||
<motion.div
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.92, y: -12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
|
||||
@@ -33,4 +33,5 @@ function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -54,4 +54,5 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -108,6 +108,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
|
||||
@@ -98,6 +98,7 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useNodes() {
|
||||
const context = useContext(NodeContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -51,4 +51,5 @@ function useDataState<T extends HTMLElement = HTMLElement>(
|
||||
return [value, localRef];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { useDataState, type DataStateValue };
|
||||
|
||||
@@ -22,4 +22,5 @@ function useIsInView<T extends HTMLElement = HTMLElement>(
|
||||
return { ref: localRef, isInView };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { useIsInView, type UseIsInViewOptions };
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function apiFetch(
|
||||
if (errData.error && errData.error.includes('not found') && errData.error.includes('Node')) {
|
||||
window.dispatchEvent(new Event('node-not-found'));
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore JSON parse errors, caller handles standard 404s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import ErrorBoundary from './components/ErrorBoundary.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
Generated
+79
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "sencho",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sencho",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "sencho",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "cd backend && npm test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/AnsoCode/Sencho.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"bugs": {
|
||||
"url": "https://github.com/AnsoCode/Sencho/issues"
|
||||
},
|
||||
"homepage": "https://github.com/AnsoCode/Sencho#readme",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright E2E config for Sencho.
|
||||
*
|
||||
* Before running: ensure both dev servers are up:
|
||||
* cd backend && npm run dev &
|
||||
* cd frontend && npm run dev &
|
||||
*
|
||||
* Or use the webServer config below (which starts them automatically).
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
// Don't stop on first failure — show all results
|
||||
maxFailures: 0,
|
||||
// How long to wait for a single test
|
||||
timeout: 30_000,
|
||||
// How long to wait for an expect() assertion
|
||||
expect: { timeout: 5_000 },
|
||||
// Run tests serially — Sencho is a single-user app and tests share DB state
|
||||
workers: 1,
|
||||
reporter: [['list'], ['html', { outputFolder: 'e2e/report', open: 'never' }]],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
// Persist auth state between tests in the same file
|
||||
storageState: undefined,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user