update
This commit is contained in:
111
.claude/CLAUDE.md
Normal file
111
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Onebox Development Notes
|
||||||
|
|
||||||
|
## ⚠️ CRITICAL DEVELOPMENT RULES ⚠️
|
||||||
|
|
||||||
|
### NEVER GUESS - ALWAYS READ THE ACTUAL CODE
|
||||||
|
**FUCKING ALWAYS look at the dependency actual code. Don't start fucking guessing stuff.**
|
||||||
|
|
||||||
|
When working with any dependency:
|
||||||
|
1. **READ the actual source code** in `node_modules/` or check the package documentation
|
||||||
|
2. **CHECK the exact API** - don't assume based on similar libraries
|
||||||
|
3. **VERIFY method names, return types, and property structures** before using them
|
||||||
|
4. **TEST with the actual implementation** - APIs change between versions
|
||||||
|
|
||||||
|
Common mistakes to avoid:
|
||||||
|
- ❌ Assuming API structure based on similar libraries
|
||||||
|
- ❌ Guessing method names or property paths
|
||||||
|
- ❌ Using outdated documentation without checking current version
|
||||||
|
- ✅ Read the actual TypeScript definitions in node_modules
|
||||||
|
- ✅ Check the package's README and changelog
|
||||||
|
- ✅ Test the actual behavior before implementing
|
||||||
|
|
||||||
|
## Architecture Changes
|
||||||
|
|
||||||
|
### Reverse Proxy Implementation
|
||||||
|
- **Replaced Nginx** with native Deno reverse proxy (`ts/classes/reverseproxy.ts`)
|
||||||
|
- Features:
|
||||||
|
- HTTP/HTTPS dual servers (ports 80/443)
|
||||||
|
- TLS/SSL certificate management with hot-reload
|
||||||
|
- WebSocket bidirectional proxying
|
||||||
|
- Dynamic routing from database
|
||||||
|
- SNI (Server Name Indication) support
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- Removed "onebox." prefix from all TypeScript files
|
||||||
|
- Organized into subfolders:
|
||||||
|
- `ts/classes/` - All class implementations
|
||||||
|
- `ts/` - Root level utilities (logging, types, plugins, cli, info)
|
||||||
|
|
||||||
|
## Debugging Tips
|
||||||
|
|
||||||
|
### Backend Logs
|
||||||
|
Use the background bash task to check server logs:
|
||||||
|
```bash
|
||||||
|
# Check for specific patterns (e.g., Login attempts)
|
||||||
|
BashOutput tool with filter: "Login|error|Error"
|
||||||
|
|
||||||
|
# Check all recent output
|
||||||
|
BashOutput tool without filter
|
||||||
|
```
|
||||||
|
|
||||||
|
The dev server runs with `--watch` so it auto-restarts on file changes.
|
||||||
|
|
||||||
|
### Frontend Testing
|
||||||
|
Use Playwright for UI testing:
|
||||||
|
```typescript
|
||||||
|
// Navigate to app
|
||||||
|
mcp__playwright__browser_navigate({ url: "http://localhost:3000" })
|
||||||
|
|
||||||
|
// Fill login form
|
||||||
|
mcp__playwright__browser_fill_form({
|
||||||
|
fields: [
|
||||||
|
{ name: "Username", type: "textbox", ref: "...", value: "admin" },
|
||||||
|
{ name: "Password", type: "textbox", ref: "...", value: "admin" }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click button
|
||||||
|
mcp__playwright__browser_click({ element: "Sign in button", ref: "..." })
|
||||||
|
|
||||||
|
// Check console errors
|
||||||
|
// Playwright automatically shows console messages in results
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Login Issue (Fixed)
|
||||||
|
**Problem**: `admin/admin` credentials returned "Invalid credentials"
|
||||||
|
|
||||||
|
**Root Cause**: `rowToUser()` function in database.ts was accessing rows as arrays `row[2]` instead of objects `row.password_hash`. The @db/sqlite library returns rows as objects with snake_case column names.
|
||||||
|
|
||||||
|
**Fix**: Updated `rowToUser()` to support both access patterns:
|
||||||
|
```typescript
|
||||||
|
private rowToUser(row: any): IUser {
|
||||||
|
return {
|
||||||
|
passwordHash: String(row.password_hash || row[2]),
|
||||||
|
// ... other fields
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location**: `ts/classes/database.ts:506-515`
|
||||||
|
|
||||||
|
## Default Credentials
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: `admin`
|
||||||
|
- ⚠️ Change immediately after first login!
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
```bash
|
||||||
|
# Main server (port 3000)
|
||||||
|
deno task dev
|
||||||
|
|
||||||
|
# Check server status
|
||||||
|
curl http://localhost:3000/api/status
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
- `POST /api/auth/login` - Login (returns JWT-like token)
|
||||||
|
- `GET /api/status` - System status (requires auth)
|
||||||
|
- `GET /api/services` - List services (requires auth)
|
||||||
|
- See `ts/classes/httpserver.ts` for full API
|
||||||
23
deno.json
23
deno.json
@@ -2,23 +2,24 @@
|
|||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno test --allow-all test/",
|
"test": "deno test --allow-all test/",
|
||||||
"test:watch": "deno test --allow-all --watch test/",
|
"test:watch": "deno test --allow-all --watch test/",
|
||||||
"compile": "bash scripts/compile-all.sh",
|
"compile": "bash scripts/compile-all.sh",
|
||||||
"dev": "deno run --allow-all --watch mod.ts"
|
"dev": "deno run --allow-all --unstable-ffi --watch mod.ts server --ephemeral --monitor"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@std/path": "jsr:@std/path@^1.0.0",
|
"@std/path": "jsr:@std/path@^1.1.2",
|
||||||
"@std/fs": "jsr:@std/fs@^1.0.0",
|
"@std/fs": "jsr:@std/fs@^1.0.19",
|
||||||
"@std/http": "jsr:@std/http@^1.0.0",
|
"@std/http": "jsr:@std/http@^1.0.21",
|
||||||
"@std/assert": "jsr:@std/assert@^1.0.0",
|
"@std/assert": "jsr:@std/assert@^1.0.15",
|
||||||
"@std/encoding": "jsr:@std/encoding@^1.0.0",
|
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||||
"@db/sqlite": "jsr:@db/sqlite@^0.11.0",
|
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
||||||
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.0.0",
|
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0",
|
||||||
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^2.0.0",
|
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@1.3.6",
|
||||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^2.0.0",
|
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^2.0.0"
|
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0"
|
||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["deno.window", "deno.ns"],
|
"lib": ["deno.window", "deno.ns"],
|
||||||
|
|||||||
@@ -48,5 +48,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64",
|
"x64",
|
||||||
"arm64"
|
"arm64"
|
||||||
]
|
],
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||||
|
"dependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
1242
pnpm-lock.yaml
generated
Normal file
1242
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
209
ts/classes/apiclient.ts
Normal file
209
ts/classes/apiclient.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* API Client for communicating with Onebox daemon
|
||||||
|
*
|
||||||
|
* Provides methods for CLI commands to interact with running daemon via HTTP API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IService,
|
||||||
|
IRegistry,
|
||||||
|
IDnsRecord,
|
||||||
|
ISslCertificate,
|
||||||
|
IServiceDeployOptions,
|
||||||
|
} from '../types.ts';
|
||||||
|
|
||||||
|
export class OneboxApiClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private token?: string;
|
||||||
|
|
||||||
|
constructor(port = 3000) {
|
||||||
|
this.baseUrl = `http://localhost:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if daemon is reachable
|
||||||
|
*/
|
||||||
|
async isReachable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/status`, {
|
||||||
|
signal: AbortSignal.timeout(5000), // 5 second timeout
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Service Operations ============
|
||||||
|
|
||||||
|
async deployService(config: IServiceDeployOptions): Promise<IService> {
|
||||||
|
return await this.request<IService>('POST', '/api/services', config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeService(name: string): Promise<void> {
|
||||||
|
await this.request('DELETE', `/api/services/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startService(name: string): Promise<void> {
|
||||||
|
await this.request('POST', `/api/services/${name}/start`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopService(name: string): Promise<void> {
|
||||||
|
await this.request('POST', `/api/services/${name}/stop`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartService(name: string): Promise<void> {
|
||||||
|
await this.request('POST', `/api/services/${name}/restart`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listServices(): Promise<IService[]> {
|
||||||
|
return await this.request<IService[]>('GET', '/api/services');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServiceLogs(name: string, limit = 1000): Promise<string[]> {
|
||||||
|
const result = await this.request<{ logs: string[] }>(
|
||||||
|
'GET',
|
||||||
|
`/api/services/${name}/logs?limit=${limit}`
|
||||||
|
);
|
||||||
|
return result.logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Registry Operations ============
|
||||||
|
|
||||||
|
async addRegistry(url: string, username: string, password: string): Promise<void> {
|
||||||
|
await this.request('POST', '/api/registries', { url, username, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeRegistry(url: string): Promise<void> {
|
||||||
|
await this.request('DELETE', `/api/registries/${encodeURIComponent(url)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRegistries(): Promise<IRegistry[]> {
|
||||||
|
return await this.request<IRegistry[]>('GET', '/api/registries');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ DNS Operations ============
|
||||||
|
|
||||||
|
async addDnsRecord(domain: string): Promise<void> {
|
||||||
|
await this.request('POST', '/api/dns', { domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDnsRecord(domain: string): Promise<void> {
|
||||||
|
await this.request('DELETE', `/api/dns/${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDnsRecords(): Promise<IDnsRecord[]> {
|
||||||
|
return await this.request<IDnsRecord[]>('GET', '/api/dns');
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncDns(): Promise<void> {
|
||||||
|
await this.request('POST', '/api/dns/sync');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ SSL Operations ============
|
||||||
|
|
||||||
|
async renewCertificate(domain?: string): Promise<void> {
|
||||||
|
const path = domain ? `/api/ssl/renew/${domain}` : '/api/ssl/renew';
|
||||||
|
await this.request('POST', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCertificates(): Promise<ISslCertificate[]> {
|
||||||
|
return await this.request<ISslCertificate[]>('GET', '/api/ssl');
|
||||||
|
}
|
||||||
|
|
||||||
|
async forceRenewCertificate(domain: string): Promise<void> {
|
||||||
|
await this.request('POST', `/api/ssl/renew/${domain}?force=true`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Nginx Operations ============
|
||||||
|
|
||||||
|
async reloadNginx(): Promise<void> {
|
||||||
|
await this.request('POST', '/api/nginx/reload');
|
||||||
|
}
|
||||||
|
|
||||||
|
async testNginx(): Promise<{ success: boolean; output: string }> {
|
||||||
|
return await this.request('POST', '/api/nginx/test');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNginxStatus(): Promise<{ status: string }> {
|
||||||
|
return await this.request('GET', '/api/nginx/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Config Operations ============
|
||||||
|
|
||||||
|
async getSettings(): Promise<Record<string, string>> {
|
||||||
|
return await this.request<Record<string, string>>('GET', '/api/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSetting(key: string, value: string): Promise<void> {
|
||||||
|
await this.request('POST', '/api/config', { key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ System Operations ============
|
||||||
|
|
||||||
|
async getStatus(): Promise<{
|
||||||
|
services: { total: number; running: number; stopped: number };
|
||||||
|
uptime: number;
|
||||||
|
}> {
|
||||||
|
return await this.request('GET', '/api/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Helper Methods ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make HTTP request to daemon
|
||||||
|
*/
|
||||||
|
private async request<T = unknown>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
options.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||||||
|
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For DELETE and some POST requests, there might be no content
|
||||||
|
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'TimeoutError') {
|
||||||
|
throw new Error('Request timed out. Daemon might be unresponsive.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set authentication token
|
||||||
|
*/
|
||||||
|
setToken(token: string): void {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,27 +4,41 @@
|
|||||||
* Handles background monitoring, metrics collection, and automatic tasks
|
* Handles background monitoring, metrics collection, and automatic tasks
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from './onebox.plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { projectInfo } from './onebox.info.ts';
|
import { projectInfo } from '../info.ts';
|
||||||
import type { Onebox } from './onebox.classes.onebox.ts';
|
import type { Onebox } from './onebox.ts';
|
||||||
|
|
||||||
|
// PID file constants
|
||||||
|
const PID_FILE_PATH = '/var/run/onebox/onebox.pid';
|
||||||
|
const PID_DIR = '/var/run/onebox';
|
||||||
|
const FALLBACK_PID_DIR = `${Deno.env.get('HOME')}/.onebox`;
|
||||||
|
const FALLBACK_PID_FILE = `${FALLBACK_PID_DIR}/onebox.pid`;
|
||||||
|
|
||||||
export class OneboxDaemon {
|
export class OneboxDaemon {
|
||||||
private oneboxRef: Onebox;
|
private oneboxRef: Onebox;
|
||||||
private smartdaemon: plugins.smartdaemon.SmartDaemon;
|
private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null;
|
||||||
private running = false;
|
private running = false;
|
||||||
private monitoringInterval: number | null = null;
|
private monitoringInterval: number | null = null;
|
||||||
private metricsInterval = 60000; // 1 minute
|
private metricsInterval = 60000; // 1 minute
|
||||||
|
private pidFilePath: string = PID_FILE_PATH;
|
||||||
|
|
||||||
constructor(oneboxRef: Onebox) {
|
constructor(oneboxRef: Onebox) {
|
||||||
this.oneboxRef = oneboxRef;
|
this.oneboxRef = oneboxRef;
|
||||||
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
|
}
|
||||||
|
|
||||||
// Get metrics interval from settings
|
/**
|
||||||
|
* Load settings from database (call after database init)
|
||||||
|
*/
|
||||||
|
private loadSettings(): void {
|
||||||
|
try {
|
||||||
const customInterval = this.oneboxRef.database.getSetting('metricsInterval');
|
const customInterval = this.oneboxRef.database.getSetting('metricsInterval');
|
||||||
if (customInterval) {
|
if (customInterval) {
|
||||||
this.metricsInterval = parseInt(customInterval, 10);
|
this.metricsInterval = parseInt(customInterval, 10);
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Database not initialized yet - use defaults
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +48,11 @@ export class OneboxDaemon {
|
|||||||
try {
|
try {
|
||||||
logger.info('Installing Onebox daemon service...');
|
logger.info('Installing Onebox daemon service...');
|
||||||
|
|
||||||
|
// Initialize smartdaemon if needed
|
||||||
|
if (!this.smartdaemon) {
|
||||||
|
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
|
||||||
|
}
|
||||||
|
|
||||||
// Get installation directory
|
// Get installation directory
|
||||||
const execPath = Deno.execPath();
|
const execPath = Deno.execPath();
|
||||||
|
|
||||||
@@ -63,6 +82,11 @@ export class OneboxDaemon {
|
|||||||
try {
|
try {
|
||||||
logger.info('Uninstalling Onebox daemon service...');
|
logger.info('Uninstalling Onebox daemon service...');
|
||||||
|
|
||||||
|
// Initialize smartdaemon if needed
|
||||||
|
if (!this.smartdaemon) {
|
||||||
|
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
|
||||||
|
}
|
||||||
|
|
||||||
const service = await this.smartdaemon.getService('onebox');
|
const service = await this.smartdaemon.getService('onebox');
|
||||||
|
|
||||||
if (service) {
|
if (service) {
|
||||||
@@ -92,6 +116,12 @@ export class OneboxDaemon {
|
|||||||
|
|
||||||
this.running = true;
|
this.running = true;
|
||||||
|
|
||||||
|
// Load settings from database
|
||||||
|
this.loadSettings();
|
||||||
|
|
||||||
|
// Write PID file
|
||||||
|
await this.writePidFile();
|
||||||
|
|
||||||
// Start monitoring loop
|
// Start monitoring loop
|
||||||
this.startMonitoring();
|
this.startMonitoring();
|
||||||
|
|
||||||
@@ -130,6 +160,9 @@ export class OneboxDaemon {
|
|||||||
// Stop HTTP server
|
// Stop HTTP server
|
||||||
await this.oneboxRef.httpServer.stop();
|
await this.oneboxRef.httpServer.stop();
|
||||||
|
|
||||||
|
// Remove PID file
|
||||||
|
await this.removePidFile();
|
||||||
|
|
||||||
logger.success('Onebox daemon stopped');
|
logger.success('Onebox daemon stopped');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to stop daemon: ${error.message}`);
|
logger.error(`Failed to stop daemon: ${error.message}`);
|
||||||
@@ -140,7 +173,7 @@ export class OneboxDaemon {
|
|||||||
/**
|
/**
|
||||||
* Start monitoring loop
|
* Start monitoring loop
|
||||||
*/
|
*/
|
||||||
private startMonitoring(): void {
|
public startMonitoring(): void {
|
||||||
logger.info('Starting monitoring loop...');
|
logger.info('Starting monitoring loop...');
|
||||||
|
|
||||||
this.monitoringInterval = setInterval(async () => {
|
this.monitoringInterval = setInterval(async () => {
|
||||||
@@ -154,7 +187,7 @@ export class OneboxDaemon {
|
|||||||
/**
|
/**
|
||||||
* Stop monitoring loop
|
* Stop monitoring loop
|
||||||
*/
|
*/
|
||||||
private stopMonitoring(): void {
|
public stopMonitoring(): void {
|
||||||
if (this.monitoringInterval !== null) {
|
if (this.monitoringInterval !== null) {
|
||||||
clearInterval(this.monitoringInterval);
|
clearInterval(this.monitoringInterval);
|
||||||
this.monitoringInterval = null;
|
this.monitoringInterval = null;
|
||||||
@@ -262,11 +295,111 @@ export class OneboxDaemon {
|
|||||||
return this.running;
|
return this.running;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write PID file
|
||||||
|
*/
|
||||||
|
private async writePidFile(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Try primary location first
|
||||||
|
try {
|
||||||
|
await Deno.mkdir(PID_DIR, { recursive: true });
|
||||||
|
await Deno.writeTextFile(PID_FILE_PATH, Deno.pid.toString());
|
||||||
|
this.pidFilePath = PID_FILE_PATH;
|
||||||
|
logger.debug(`PID file written: ${PID_FILE_PATH}`);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
// Permission denied - try fallback location
|
||||||
|
logger.debug(`Cannot write to ${PID_DIR}, using fallback location`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to user directory
|
||||||
|
await Deno.mkdir(FALLBACK_PID_DIR, { recursive: true });
|
||||||
|
await Deno.writeTextFile(FALLBACK_PID_FILE, Deno.pid.toString());
|
||||||
|
this.pidFilePath = FALLBACK_PID_FILE;
|
||||||
|
logger.debug(`PID file written: ${FALLBACK_PID_FILE}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to write PID file: ${error.message}`);
|
||||||
|
// Non-fatal - daemon can still run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove PID file
|
||||||
|
*/
|
||||||
|
private async removePidFile(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await Deno.remove(this.pidFilePath);
|
||||||
|
logger.debug(`PID file removed: ${this.pidFilePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors - file might not exist
|
||||||
|
logger.debug(`Could not remove PID file: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if daemon is running
|
||||||
|
*/
|
||||||
|
static async isDaemonRunning(): Promise<boolean> {
|
||||||
|
const pid = await OneboxDaemon.getDaemonPid();
|
||||||
|
if (!pid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if process exists
|
||||||
|
await Deno.stat(`/proc/${pid}`);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Process doesn't exist - clean up stale PID file
|
||||||
|
logger.debug(`Cleaning up stale PID file`);
|
||||||
|
try {
|
||||||
|
await Deno.remove(PID_FILE_PATH);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await Deno.remove(FALLBACK_PID_FILE);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daemon PID
|
||||||
|
*/
|
||||||
|
static async getDaemonPid(): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
// Try primary location
|
||||||
|
try {
|
||||||
|
const pidText = await Deno.readTextFile(PID_FILE_PATH);
|
||||||
|
return parseInt(pidText.trim(), 10);
|
||||||
|
} catch {
|
||||||
|
// Try fallback location
|
||||||
|
const pidText = await Deno.readTextFile(FALLBACK_PID_FILE);
|
||||||
|
return parseInt(pidText.trim(), 10);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure no daemon is running
|
||||||
|
*/
|
||||||
|
static async ensureNoDaemon(): Promise<void> {
|
||||||
|
const running = await OneboxDaemon.isDaemonRunning();
|
||||||
|
if (running) {
|
||||||
|
throw new Error('Daemon is already running. Please stop it first with: onebox daemon stop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get service status from systemd
|
* Get service status from systemd
|
||||||
*/
|
*/
|
||||||
async getServiceStatus(): Promise<string> {
|
async getServiceStatus(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
// Don't need smartdaemon to check status, just use systemctl directly
|
||||||
const command = new Deno.Command('systemctl', {
|
const command = new Deno.Command('systemctl', {
|
||||||
args: ['status', 'smartdaemon_onebox'],
|
args: ['status', 'smartdaemon_onebox'],
|
||||||
stdout: 'piped',
|
stdout: 'piped',
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Database layer for Onebox using SQLite
|
* Database layer for Onebox using SQLite
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from './onebox.plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import type {
|
import type {
|
||||||
IService,
|
IService,
|
||||||
IRegistry,
|
IRegistry,
|
||||||
@@ -13,14 +13,14 @@ import type {
|
|||||||
ILogEntry,
|
ILogEntry,
|
||||||
IUser,
|
IUser,
|
||||||
ISetting,
|
ISetting,
|
||||||
} from './onebox.types.ts';
|
} from '../types.ts';
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
|
|
||||||
export class OneboxDatabase {
|
export class OneboxDatabase {
|
||||||
private db: plugins.sqlite.DB | null = null;
|
private db: plugins.sqlite.DB | null = null;
|
||||||
private dbPath: string;
|
private dbPath: string;
|
||||||
|
|
||||||
constructor(dbPath = '/var/lib/onebox/onebox.db') {
|
constructor(dbPath = './.nogit/onebox.db') {
|
||||||
this.dbPath = dbPath;
|
this.dbPath = dbPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export class OneboxDatabase {
|
|||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
// Services table
|
// Services table
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS services (
|
CREATE TABLE IF NOT EXISTS services (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
@@ -72,7 +72,7 @@ export class OneboxDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Registries table
|
// Registries table
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS registries (
|
CREATE TABLE IF NOT EXISTS registries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
url TEXT NOT NULL UNIQUE,
|
url TEXT NOT NULL UNIQUE,
|
||||||
@@ -83,7 +83,7 @@ export class OneboxDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Nginx configs table
|
// Nginx configs table
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS nginx_configs (
|
CREATE TABLE IF NOT EXISTS nginx_configs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
service_id INTEGER NOT NULL,
|
service_id INTEGER NOT NULL,
|
||||||
@@ -98,7 +98,7 @@ export class OneboxDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// SSL certificates table
|
// SSL certificates table
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS ssl_certificates (
|
CREATE TABLE IF NOT EXISTS ssl_certificates (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
domain TEXT NOT NULL UNIQUE,
|
domain TEXT NOT NULL UNIQUE,
|
||||||
@@ -113,7 +113,7 @@ export class OneboxDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// DNS records table
|
// DNS records table
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS dns_records (
|
CREATE TABLE IF NOT EXISTS dns_records (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
domain TEXT NOT NULL UNIQUE,
|
domain TEXT NOT NULL UNIQUE,
|
||||||
@@ -126,7 +126,7 @@ export class OneboxDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Metrics table
|
// Metrics table
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS metrics (
|
CREATE TABLE IF NOT EXISTS metrics (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
service_id INTEGER NOT NULL,
|
service_id INTEGER NOT NULL,
|
||||||
@@ -141,13 +141,13 @@ export class OneboxDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create index for metrics queries
|
// Create index for metrics queries
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp
|
CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp
|
||||||
ON metrics(service_id, timestamp DESC)
|
ON metrics(service_id, timestamp DESC)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Logs table
|
// Logs table
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS logs (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
service_id INTEGER NOT NULL,
|
service_id INTEGER NOT NULL,
|
||||||
@@ -160,13 +160,13 @@ export class OneboxDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create index for logs queries
|
// Create index for logs queries
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp
|
CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp
|
||||||
ON logs(service_id, timestamp DESC)
|
ON logs(service_id, timestamp DESC)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Users table
|
// Users table
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
@@ -178,7 +178,7 @@ export class OneboxDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Settings table
|
// Settings table
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
@@ -187,7 +187,7 @@ export class OneboxDatabase {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Version table for migrations
|
// Version table for migrations
|
||||||
this.db.query(`
|
this.query(`
|
||||||
CREATE TABLE IF NOT EXISTS migrations (
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
applied_at INTEGER NOT NULL
|
applied_at INTEGER NOT NULL
|
||||||
@@ -220,7 +220,7 @@ export class OneboxDatabase {
|
|||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = this.db.query('SELECT MAX(version) as version FROM migrations');
|
const result = this.query('SELECT MAX(version) as version FROM migrations');
|
||||||
return result.length > 0 && result[0][0] !== null ? Number(result[0][0]) : 0;
|
return result.length > 0 && result[0][0] !== null ? Number(result[0][0]) : 0;
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -233,7 +233,7 @@ export class OneboxDatabase {
|
|||||||
private setMigrationVersion(version: number): void {
|
private setMigrationVersion(version: number): void {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
this.db.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [
|
this.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [
|
||||||
version,
|
version,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
]);
|
]);
|
||||||
@@ -255,8 +255,27 @@ export class OneboxDatabase {
|
|||||||
* Execute a raw query
|
* Execute a raw query
|
||||||
*/
|
*/
|
||||||
query<T = unknown[]>(sql: string, params: unknown[] = []): T[] {
|
query<T = unknown[]>(sql: string, params: unknown[] = []): T[] {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) {
|
||||||
return this.db.query(sql, params) as T[];
|
const error = new Error('Database not initialized');
|
||||||
|
console.error('Database access before initialization!');
|
||||||
|
console.error('Stack trace:', error.stack);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For queries without parameters, use exec for better performance
|
||||||
|
if (params.length === 0 && !sql.trim().toUpperCase().startsWith('SELECT')) {
|
||||||
|
this.db.exec(sql);
|
||||||
|
return [] as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For SELECT queries or statements with parameters, use prepare().all()
|
||||||
|
const stmt = this.db.prepare(sql);
|
||||||
|
try {
|
||||||
|
const result = stmt.all(...params);
|
||||||
|
return result as T[];
|
||||||
|
} finally {
|
||||||
|
stmt.finalize();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Services CRUD ============
|
// ============ Services CRUD ============
|
||||||
@@ -265,7 +284,7 @@ export class OneboxDatabase {
|
|||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.db.query(
|
this.query(
|
||||||
`INSERT INTO services (name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at)
|
`INSERT INTO services (name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
@@ -288,21 +307,21 @@ export class OneboxDatabase {
|
|||||||
getServiceByName(name: string): IService | null {
|
getServiceByName(name: string): IService | null {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT * FROM services WHERE name = ?', [name]);
|
const rows = this.query('SELECT * FROM services WHERE name = ?', [name]);
|
||||||
return rows.length > 0 ? this.rowToService(rows[0]) : null;
|
return rows.length > 0 ? this.rowToService(rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getServiceByID(id: number): IService | null {
|
getServiceByID(id: number): IService | null {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT * FROM services WHERE id = ?', [id]);
|
const rows = this.query('SELECT * FROM services WHERE id = ?', [id]);
|
||||||
return rows.length > 0 ? this.rowToService(rows[0]) : null;
|
return rows.length > 0 ? this.rowToService(rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllServices(): IService[] {
|
getAllServices(): IService[] {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT * FROM services ORDER BY created_at DESC');
|
const rows = this.query('SELECT * FROM services ORDER BY created_at DESC');
|
||||||
return rows.map((row) => this.rowToService(row));
|
return rows.map((row) => this.rowToService(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,12 +364,12 @@ export class OneboxDatabase {
|
|||||||
values.push(Date.now());
|
values.push(Date.now());
|
||||||
values.push(id);
|
values.push(id);
|
||||||
|
|
||||||
this.db.query(`UPDATE services SET ${fields.join(', ')} WHERE id = ?`, values);
|
this.query(`UPDATE services SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteService(id: number): void {
|
deleteService(id: number): void {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
this.db.query('DELETE FROM services WHERE id = ?', [id]);
|
this.query('DELETE FROM services WHERE id = ?', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rowToService(row: unknown[]): IService {
|
private rowToService(row: unknown[]): IService {
|
||||||
@@ -375,7 +394,7 @@ export class OneboxDatabase {
|
|||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.db.query(
|
this.query(
|
||||||
'INSERT INTO registries (url, username, password_encrypted, created_at) VALUES (?, ?, ?, ?)',
|
'INSERT INTO registries (url, username, password_encrypted, created_at) VALUES (?, ?, ?, ?)',
|
||||||
[registry.url, registry.username, registry.passwordEncrypted, now]
|
[registry.url, registry.username, registry.passwordEncrypted, now]
|
||||||
);
|
);
|
||||||
@@ -386,20 +405,20 @@ export class OneboxDatabase {
|
|||||||
getRegistryByURL(url: string): IRegistry | null {
|
getRegistryByURL(url: string): IRegistry | null {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT * FROM registries WHERE url = ?', [url]);
|
const rows = this.query('SELECT * FROM registries WHERE url = ?', [url]);
|
||||||
return rows.length > 0 ? this.rowToRegistry(rows[0]) : null;
|
return rows.length > 0 ? this.rowToRegistry(rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllRegistries(): IRegistry[] {
|
getAllRegistries(): IRegistry[] {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT * FROM registries ORDER BY created_at DESC');
|
const rows = this.query('SELECT * FROM registries ORDER BY created_at DESC');
|
||||||
return rows.map((row) => this.rowToRegistry(row));
|
return rows.map((row) => this.rowToRegistry(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteRegistry(url: string): void {
|
deleteRegistry(url: string): void {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
this.db.query('DELETE FROM registries WHERE url = ?', [url]);
|
this.query('DELETE FROM registries WHERE url = ?', [url]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rowToRegistry(row: unknown[]): IRegistry {
|
private rowToRegistry(row: unknown[]): IRegistry {
|
||||||
@@ -417,7 +436,7 @@ export class OneboxDatabase {
|
|||||||
getSetting(key: string): string | null {
|
getSetting(key: string): string | null {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT value FROM settings WHERE key = ?', [key]);
|
const rows = this.query('SELECT value FROM settings WHERE key = ?', [key]);
|
||||||
return rows.length > 0 ? String(rows[0][0]) : null;
|
return rows.length > 0 ? String(rows[0][0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +444,7 @@ export class OneboxDatabase {
|
|||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.db.query(
|
this.query(
|
||||||
'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
|
'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
|
||||||
[key, value, now]
|
[key, value, now]
|
||||||
);
|
);
|
||||||
@@ -434,10 +453,13 @@ export class OneboxDatabase {
|
|||||||
getAllSettings(): Record<string, string> {
|
getAllSettings(): Record<string, string> {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT key, value FROM settings');
|
const rows = this.query('SELECT key, value FROM settings');
|
||||||
const settings: Record<string, string> = {};
|
const settings: Record<string, string> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
settings[String(row[0])] = String(row[1]);
|
// @db/sqlite returns rows as objects with column names as keys
|
||||||
|
const key = (row as any).key || row[0];
|
||||||
|
const value = (row as any).value || row[1];
|
||||||
|
settings[String(key)] = String(value);
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
@@ -448,7 +470,7 @@ export class OneboxDatabase {
|
|||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.db.query(
|
this.query(
|
||||||
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
[user.username, user.passwordHash, user.role, now, now]
|
[user.username, user.passwordHash, user.role, now, now]
|
||||||
);
|
);
|
||||||
@@ -459,20 +481,20 @@ export class OneboxDatabase {
|
|||||||
getUserByUsername(username: string): IUser | null {
|
getUserByUsername(username: string): IUser | null {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT * FROM users WHERE username = ?', [username]);
|
const rows = this.query('SELECT * FROM users WHERE username = ?', [username]);
|
||||||
return rows.length > 0 ? this.rowToUser(rows[0]) : null;
|
return rows.length > 0 ? this.rowToUser(rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllUsers(): IUser[] {
|
getAllUsers(): IUser[] {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT * FROM users ORDER BY created_at DESC');
|
const rows = this.query('SELECT * FROM users ORDER BY created_at DESC');
|
||||||
return rows.map((row) => this.rowToUser(row));
|
return rows.map((row) => this.rowToUser(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUserPassword(username: string, passwordHash: string): void {
|
updateUserPassword(username: string, passwordHash: string): void {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
this.db.query('UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?', [
|
this.query('UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?', [
|
||||||
passwordHash,
|
passwordHash,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
username,
|
username,
|
||||||
@@ -481,17 +503,17 @@ export class OneboxDatabase {
|
|||||||
|
|
||||||
deleteUser(username: string): void {
|
deleteUser(username: string): void {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
this.db.query('DELETE FROM users WHERE username = ?', [username]);
|
this.query('DELETE FROM users WHERE username = ?', [username]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rowToUser(row: unknown[]): IUser {
|
private rowToUser(row: any): IUser {
|
||||||
return {
|
return {
|
||||||
id: Number(row[0]),
|
id: Number(row.id || row[0]),
|
||||||
username: String(row[1]),
|
username: String(row.username || row[1]),
|
||||||
passwordHash: String(row[2]),
|
passwordHash: String(row.password_hash || row[2]),
|
||||||
role: String(row[3]) as IUser['role'],
|
role: String(row.role || row[3]) as IUser['role'],
|
||||||
createdAt: Number(row[4]),
|
createdAt: Number(row.created_at || row[4]),
|
||||||
updatedAt: Number(row[5]),
|
updatedAt: Number(row.updated_at || row[5]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +522,7 @@ export class OneboxDatabase {
|
|||||||
addMetric(metric: Omit<IMetric, 'id'>): void {
|
addMetric(metric: Omit<IMetric, 'id'>): void {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
this.db.query(
|
this.query(
|
||||||
`INSERT INTO metrics (service_id, timestamp, cpu_percent, memory_used, memory_limit, network_rx_bytes, network_tx_bytes)
|
`INSERT INTO metrics (service_id, timestamp, cpu_percent, memory_used, memory_limit, network_rx_bytes, network_tx_bytes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
@@ -518,7 +540,7 @@ export class OneboxDatabase {
|
|||||||
getMetrics(serviceId: number, limit = 100): IMetric[] {
|
getMetrics(serviceId: number, limit = 100): IMetric[] {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query(
|
const rows = this.query(
|
||||||
'SELECT * FROM metrics WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?',
|
'SELECT * FROM metrics WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?',
|
||||||
[serviceId, limit]
|
[serviceId, limit]
|
||||||
);
|
);
|
||||||
@@ -543,7 +565,7 @@ export class OneboxDatabase {
|
|||||||
addLog(log: Omit<ILogEntry, 'id'>): void {
|
addLog(log: Omit<ILogEntry, 'id'>): void {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
this.db.query(
|
this.query(
|
||||||
'INSERT INTO logs (service_id, timestamp, message, level, source) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO logs (service_id, timestamp, message, level, source) VALUES (?, ?, ?, ?, ?)',
|
||||||
[log.serviceId, log.timestamp, log.message, log.level, log.source]
|
[log.serviceId, log.timestamp, log.message, log.level, log.source]
|
||||||
);
|
);
|
||||||
@@ -552,7 +574,7 @@ export class OneboxDatabase {
|
|||||||
getLogs(serviceId: number, limit = 1000): ILogEntry[] {
|
getLogs(serviceId: number, limit = 1000): ILogEntry[] {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query(
|
const rows = this.query(
|
||||||
'SELECT * FROM logs WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?',
|
'SELECT * FROM logs WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?',
|
||||||
[serviceId, limit]
|
[serviceId, limit]
|
||||||
);
|
);
|
||||||
@@ -576,7 +598,7 @@ export class OneboxDatabase {
|
|||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.db.query(
|
this.query(
|
||||||
`INSERT INTO ssl_certificates (domain, cert_path, key_path, full_chain_path, expiry_date, issuer, created_at, updated_at)
|
`INSERT INTO ssl_certificates (domain, cert_path, key_path, full_chain_path, expiry_date, issuer, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
@@ -597,14 +619,14 @@ export class OneboxDatabase {
|
|||||||
getSSLCertificate(domain: string): ISslCertificate | null {
|
getSSLCertificate(domain: string): ISslCertificate | null {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT * FROM ssl_certificates WHERE domain = ?', [domain]);
|
const rows = this.query('SELECT * FROM ssl_certificates WHERE domain = ?', [domain]);
|
||||||
return rows.length > 0 ? this.rowToSSLCert(rows[0]) : null;
|
return rows.length > 0 ? this.rowToSSLCert(rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllSSLCertificates(): ISslCertificate[] {
|
getAllSSLCertificates(): ISslCertificate[] {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
const rows = this.db.query('SELECT * FROM ssl_certificates ORDER BY expiry_date ASC');
|
const rows = this.query('SELECT * FROM ssl_certificates ORDER BY expiry_date ASC');
|
||||||
return rows.map((row) => this.rowToSSLCert(row));
|
return rows.map((row) => this.rowToSSLCert(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,12 +657,12 @@ export class OneboxDatabase {
|
|||||||
values.push(Date.now());
|
values.push(Date.now());
|
||||||
values.push(domain);
|
values.push(domain);
|
||||||
|
|
||||||
this.db.query(`UPDATE ssl_certificates SET ${fields.join(', ')} WHERE domain = ?`, values);
|
this.query(`UPDATE ssl_certificates SET ${fields.join(', ')} WHERE domain = ?`, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteSSLCertificate(domain: string): void {
|
deleteSSLCertificate(domain: string): void {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
this.db.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]);
|
this.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rowToSSLCert(row: unknown[]): ISslCertificate {
|
private rowToSSLCert(row: unknown[]): ISslCertificate {
|
||||||
@@ -4,14 +4,14 @@
|
|||||||
* Manages DNS records via Cloudflare API
|
* Manages DNS records via Cloudflare API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from './onebox.plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
import { OneboxDatabase } from './database.ts';
|
||||||
|
|
||||||
export class OneboxDnsManager {
|
export class OneboxDnsManager {
|
||||||
private oneboxRef: any;
|
private oneboxRef: any;
|
||||||
private database: OneboxDatabase;
|
private database: OneboxDatabase;
|
||||||
private cloudflareClient: plugins.cloudflare.Cloudflare | null = null;
|
private cloudflareClient: plugins.cloudflare.CloudflareAccount | null = null;
|
||||||
private zoneID: string | null = null;
|
private zoneID: string | null = null;
|
||||||
private serverIP: string | null = null;
|
private serverIP: string | null = null;
|
||||||
|
|
||||||
@@ -28,27 +28,58 @@ export class OneboxDnsManager {
|
|||||||
// Get Cloudflare credentials from settings
|
// Get Cloudflare credentials from settings
|
||||||
const apiKey = this.database.getSetting('cloudflareAPIKey');
|
const apiKey = this.database.getSetting('cloudflareAPIKey');
|
||||||
const email = this.database.getSetting('cloudflareEmail');
|
const email = this.database.getSetting('cloudflareEmail');
|
||||||
const zoneID = this.database.getSetting('cloudflareZoneID');
|
|
||||||
const serverIP = this.database.getSetting('serverIP');
|
const serverIP = this.database.getSetting('serverIP');
|
||||||
|
|
||||||
if (!apiKey || !email || !zoneID) {
|
if (!apiKey || !email) {
|
||||||
logger.warn('Cloudflare credentials not configured. DNS management will be disabled.');
|
logger.warn('Cloudflare credentials not configured. DNS management will be disabled.');
|
||||||
logger.info('Configure with: onebox config set cloudflareAPIKey <key>');
|
logger.info('Configure with: onebox config set cloudflareAPIKey <key>');
|
||||||
|
logger.info('Configure with: onebox config set cloudflareEmail <email>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.zoneID = zoneID;
|
|
||||||
this.serverIP = serverIP;
|
this.serverIP = serverIP;
|
||||||
|
|
||||||
// Initialize Cloudflare client
|
// Initialize Cloudflare client
|
||||||
this.cloudflareClient = new plugins.cloudflare.Cloudflare({
|
// The CloudflareAccount class expects just the API key/token
|
||||||
apiKey,
|
this.cloudflareClient = new plugins.cloudflare.CloudflareAccount(apiKey);
|
||||||
email,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Try to get zone ID from settings, or auto-detect
|
||||||
|
let zoneID = this.database.getSetting('cloudflareZoneID');
|
||||||
|
|
||||||
|
if (!zoneID) {
|
||||||
|
// Auto-detect zones
|
||||||
|
logger.info('No zone ID configured, fetching available zones...');
|
||||||
|
const zones = await this.cloudflareClient.convenience.listZones();
|
||||||
|
|
||||||
|
if (zones.length === 0) {
|
||||||
|
logger.warn('No Cloudflare zones found for this account');
|
||||||
|
return;
|
||||||
|
} else if (zones.length === 1) {
|
||||||
|
// Auto-select the only zone
|
||||||
|
zoneID = zones[0].id;
|
||||||
|
this.database.setSetting('cloudflareZoneID', zoneID);
|
||||||
|
logger.success(`Auto-selected zone: ${zones[0].name} (${zoneID})`);
|
||||||
|
} else {
|
||||||
|
// Multiple zones found - log them for user to choose
|
||||||
|
logger.info(`Found ${zones.length} Cloudflare zones:`);
|
||||||
|
for (const zone of zones) {
|
||||||
|
logger.info(` - ${zone.name} (ID: ${zone.id})`);
|
||||||
|
}
|
||||||
|
logger.warn('Multiple zones found. Please set one with: onebox config set cloudflareZoneID <id>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.zoneID = zoneID;
|
||||||
logger.info('DNS manager initialized with Cloudflare');
|
logger.info('DNS manager initialized with Cloudflare');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to initialize DNS manager: ${error.message}`);
|
logger.error(`Failed to initialize DNS manager: ${error.message}`);
|
||||||
|
if (error.message && error.message.includes('Authorization header')) {
|
||||||
|
logger.error('The provided API key appears to be invalid.');
|
||||||
|
logger.error('Make sure you are using a Cloudflare API TOKEN (not the global API key).');
|
||||||
|
logger.info('Create an API Token at: https://dash.cloudflare.com/profile/api-tokens');
|
||||||
|
logger.info('The token needs "Zone:Read" and "DNS:Edit" permissions.');
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
* Handles all Docker operations: containers, images, networks, volumes
|
* Handles all Docker operations: containers, images, networks, volumes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from './onebox.plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import type { IService, IContainerStats } from './onebox.types.ts';
|
import type { IService, IContainerStats } from '../types.ts';
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
|
|
||||||
export class OneboxDockerManager {
|
export class OneboxDockerManager {
|
||||||
private dockerClient: plugins.docker.Docker | null = null;
|
private dockerClient: plugins.docker.Docker | null = null;
|
||||||
@@ -22,6 +22,9 @@ export class OneboxDockerManager {
|
|||||||
socketPath: '/var/run/docker.sock',
|
socketPath: '/var/run/docker.sock',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start the Docker client
|
||||||
|
await this.dockerClient.start();
|
||||||
|
|
||||||
logger.info('Docker client initialized');
|
logger.info('Docker client initialized');
|
||||||
|
|
||||||
// Ensure onebox network exists
|
// Ensure onebox network exists
|
||||||
@@ -37,8 +40,8 @@ export class OneboxDockerManager {
|
|||||||
*/
|
*/
|
||||||
private async ensureNetwork(): Promise<void> {
|
private async ensureNetwork(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const networks = await this.dockerClient!.listNetworks();
|
const networks = await this.dockerClient!.getNetworks();
|
||||||
const existingNetwork = networks.find((n: any) => n.Name === this.networkName);
|
const existingNetwork = networks.find((n: any) => n.name === this.networkName);
|
||||||
|
|
||||||
if (!existingNetwork) {
|
if (!existingNetwork) {
|
||||||
logger.info(`Creating Docker network: ${this.networkName}`);
|
logger.info(`Creating Docker network: ${this.networkName}`);
|
||||||
@@ -370,14 +373,11 @@ export class OneboxDockerManager {
|
|||||||
*/
|
*/
|
||||||
async listContainers(): Promise<any[]> {
|
async listContainers(): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
const containers = await this.dockerClient!.listContainers({
|
const containers = await this.dockerClient!.getContainers();
|
||||||
all: true,
|
// Filter for onebox-managed containers
|
||||||
filters: {
|
return containers.filter((c: any) =>
|
||||||
label: ['managed-by=onebox'],
|
c.labels && c.labels['managed-by'] === 'onebox'
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
|
||||||
return containers;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to list containers: ${error.message}`);
|
logger.error(`Failed to list containers: ${error.message}`);
|
||||||
return [];
|
return [];
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
* Serves REST API and Angular UI
|
* Serves REST API and Angular UI
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from './onebox.plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import type { Onebox } from './onebox.classes.onebox.ts';
|
import type { Onebox } from './onebox.ts';
|
||||||
import type { IApiResponse } from './onebox.types.ts';
|
import type { IApiResponse } from '../types.ts';
|
||||||
|
|
||||||
export class OneboxHttpServer {
|
export class OneboxHttpServer {
|
||||||
private oneboxRef: Onebox;
|
private oneboxRef: Onebox;
|
||||||
@@ -75,16 +75,93 @@ export class OneboxHttpServer {
|
|||||||
return await this.handleApiRequest(req, path);
|
return await this.handleApiRequest(req, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve Angular UI (TODO: implement static file serving)
|
// Serve Angular UI
|
||||||
return new Response('Onebox API - UI coming soon', {
|
return await this.serveStaticFile(path);
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Request error: ${error.message}`);
|
logger.error(`Request error: ${error.message}`);
|
||||||
return this.jsonResponse({ success: false, error: error.message }, 500);
|
return this.jsonResponse({ success: false, error: error.message }, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve static files from ui/dist
|
||||||
|
*/
|
||||||
|
private async serveStaticFile(path: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Default to index.html for root and non-file paths
|
||||||
|
let filePath = path === '/' ? '/index.html' : path;
|
||||||
|
|
||||||
|
// For Angular routing - serve index.html for non-asset paths
|
||||||
|
if (!filePath.includes('.') && filePath !== '/index.html') {
|
||||||
|
filePath = '/index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = `./ui/dist${filePath}`;
|
||||||
|
|
||||||
|
// Read file
|
||||||
|
const file = await Deno.readFile(fullPath);
|
||||||
|
|
||||||
|
// Determine content type
|
||||||
|
const contentType = this.getContentType(filePath);
|
||||||
|
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': filePath === '/index.html' ? 'no-cache' : 'public, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// File not found - serve index.html for Angular routing
|
||||||
|
if (error instanceof Deno.errors.NotFound) {
|
||||||
|
try {
|
||||||
|
const indexFile = await Deno.readFile('./ui/dist/index.html');
|
||||||
|
return new Response(indexFile, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response('UI not built. Run: cd ui && npm run build', {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('File not found', {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get content type for file
|
||||||
|
*/
|
||||||
|
private getContentType(path: string): string {
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'html': 'text/html',
|
||||||
|
'css': 'text/css',
|
||||||
|
'js': 'application/javascript',
|
||||||
|
'json': 'application/json',
|
||||||
|
'png': 'image/png',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'svg': 'image/svg+xml',
|
||||||
|
'ico': 'image/x-icon',
|
||||||
|
'woff': 'font/woff',
|
||||||
|
'woff2': 'font/woff2',
|
||||||
|
'ttf': 'font/ttf',
|
||||||
|
'eot': 'application/vnd.ms-fontobject',
|
||||||
|
};
|
||||||
|
|
||||||
|
return mimeTypes[ext || ''] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle API requests
|
* Handle API requests
|
||||||
*/
|
*/
|
||||||
@@ -101,8 +178,14 @@ export class OneboxHttpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Route to appropriate handler
|
// Route to appropriate handler
|
||||||
if (path === '/api/status' && method === 'GET') {
|
if (path === '/api/auth/login' && method === 'POST') {
|
||||||
|
return await this.handleLoginRequest(req);
|
||||||
|
} else if (path === '/api/status' && method === 'GET') {
|
||||||
return await this.handleStatusRequest();
|
return await this.handleStatusRequest();
|
||||||
|
} else if (path === '/api/settings' && method === 'GET') {
|
||||||
|
return await this.handleGetSettingsRequest();
|
||||||
|
} else if (path === '/api/settings' && method === 'PUT') {
|
||||||
|
return await this.handleUpdateSettingsRequest(req);
|
||||||
} else if (path === '/api/services' && method === 'GET') {
|
} else if (path === '/api/services' && method === 'GET') {
|
||||||
return await this.handleListServicesRequest();
|
return await this.handleListServicesRequest();
|
||||||
} else if (path === '/api/services' && method === 'POST') {
|
} else if (path === '/api/services' && method === 'POST') {
|
||||||
@@ -132,6 +215,58 @@ export class OneboxHttpServer {
|
|||||||
|
|
||||||
// API Handlers
|
// API Handlers
|
||||||
|
|
||||||
|
private async handleLoginRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { username, password } = body;
|
||||||
|
|
||||||
|
logger.info(`Login attempt for user: ${username}`);
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return this.jsonResponse(
|
||||||
|
{ success: false, error: 'Username and password required' },
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
const user = this.oneboxRef.database.getUserByUsername(username);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.info(`User not found: ${username}`);
|
||||||
|
return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`User found: ${username}, checking password...`);
|
||||||
|
|
||||||
|
// Verify password (simple base64 comparison for now)
|
||||||
|
const passwordHash = btoa(password);
|
||||||
|
logger.info(`Password hash: ${passwordHash}, stored hash: ${user.passwordHash}`);
|
||||||
|
|
||||||
|
if (passwordHash !== user.passwordHash) {
|
||||||
|
logger.info(`Password mismatch for user: ${username}`);
|
||||||
|
return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate simple token (in production, use proper JWT)
|
||||||
|
const token = btoa(`${user.username}:${Date.now()}`);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Login error: ${error.message}`);
|
||||||
|
return this.jsonResponse({ success: false, error: 'Login failed' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleStatusRequest(): Promise<Response> {
|
private async handleStatusRequest(): Promise<Response> {
|
||||||
const status = await this.oneboxRef.getSystemStatus();
|
const status = await this.oneboxRef.getSystemStatus();
|
||||||
return this.jsonResponse({ success: true, data: status });
|
return this.jsonResponse({ success: true, data: status });
|
||||||
@@ -181,6 +316,40 @@ export class OneboxHttpServer {
|
|||||||
return this.jsonResponse({ success: true, data: logs });
|
return this.jsonResponse({ success: true, data: logs });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleGetSettingsRequest(): Promise<Response> {
|
||||||
|
const settings = this.oneboxRef.database.getAllSettings();
|
||||||
|
return this.jsonResponse({ success: true, data: settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUpdateSettingsRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return this.jsonResponse(
|
||||||
|
{ success: false, error: 'Invalid request body' },
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each setting
|
||||||
|
for (const [key, value] of Object.entries(body)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
this.oneboxRef.database.setSetting(key, value);
|
||||||
|
logger.info(`Setting updated: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Settings updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update settings: ${error.message}`);
|
||||||
|
return this.jsonResponse({ success: false, error: 'Failed to update settings' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to create JSON response
|
* Helper to create JSON response
|
||||||
*/
|
*/
|
||||||
@@ -4,23 +4,23 @@
|
|||||||
* Coordinates all components and provides the main API
|
* Coordinates all components and provides the main API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
import { OneboxDatabase } from './database.ts';
|
||||||
import { OneboxDockerManager } from './onebox.classes.docker.ts';
|
import { OneboxDockerManager } from './docker.ts';
|
||||||
import { OneboxServicesManager } from './onebox.classes.services.ts';
|
import { OneboxServicesManager } from './services.ts';
|
||||||
import { OneboxRegistriesManager } from './onebox.classes.registries.ts';
|
import { OneboxRegistriesManager } from './registries.ts';
|
||||||
import { OneboxNginxManager } from './onebox.classes.nginx.ts';
|
import { OneboxReverseProxy } from './reverseproxy.ts';
|
||||||
import { OneboxDnsManager } from './onebox.classes.dns.ts';
|
import { OneboxDnsManager } from './dns.ts';
|
||||||
import { OneboxSslManager } from './onebox.classes.ssl.ts';
|
import { OneboxSslManager } from './ssl.ts';
|
||||||
import { OneboxDaemon } from './onebox.classes.daemon.ts';
|
import { OneboxDaemon } from './daemon.ts';
|
||||||
import { OneboxHttpServer } from './onebox.classes.httpserver.ts';
|
import { OneboxHttpServer } from './httpserver.ts';
|
||||||
|
|
||||||
export class Onebox {
|
export class Onebox {
|
||||||
public database: OneboxDatabase;
|
public database: OneboxDatabase;
|
||||||
public docker: OneboxDockerManager;
|
public docker: OneboxDockerManager;
|
||||||
public services: OneboxServicesManager;
|
public services: OneboxServicesManager;
|
||||||
public registries: OneboxRegistriesManager;
|
public registries: OneboxRegistriesManager;
|
||||||
public nginx: OneboxNginxManager;
|
public reverseProxy: OneboxReverseProxy;
|
||||||
public dns: OneboxDnsManager;
|
public dns: OneboxDnsManager;
|
||||||
public ssl: OneboxSslManager;
|
public ssl: OneboxSslManager;
|
||||||
public daemon: OneboxDaemon;
|
public daemon: OneboxDaemon;
|
||||||
@@ -36,7 +36,7 @@ export class Onebox {
|
|||||||
this.docker = new OneboxDockerManager();
|
this.docker = new OneboxDockerManager();
|
||||||
this.services = new OneboxServicesManager(this);
|
this.services = new OneboxServicesManager(this);
|
||||||
this.registries = new OneboxRegistriesManager(this);
|
this.registries = new OneboxRegistriesManager(this);
|
||||||
this.nginx = new OneboxNginxManager(this);
|
this.reverseProxy = new OneboxReverseProxy(this);
|
||||||
this.dns = new OneboxDnsManager(this);
|
this.dns = new OneboxDnsManager(this);
|
||||||
this.ssl = new OneboxSslManager(this);
|
this.ssl = new OneboxSslManager(this);
|
||||||
this.daemon = new OneboxDaemon(this);
|
this.daemon = new OneboxDaemon(this);
|
||||||
@@ -59,8 +59,8 @@ export class Onebox {
|
|||||||
// Initialize Docker
|
// Initialize Docker
|
||||||
await this.docker.init();
|
await this.docker.init();
|
||||||
|
|
||||||
// Initialize Nginx
|
// Initialize Reverse Proxy
|
||||||
await this.nginx.init();
|
await this.reverseProxy.init();
|
||||||
|
|
||||||
// Initialize DNS (non-critical)
|
// Initialize DNS (non-critical)
|
||||||
try {
|
try {
|
||||||
@@ -97,15 +97,12 @@ export class Onebox {
|
|||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
logger.info('Creating default admin user...');
|
logger.info('Creating default admin user...');
|
||||||
|
|
||||||
// Hash default password 'admin'
|
// Simple base64 encoding for now - should use bcrypt in production
|
||||||
const passwordHash = await Deno.readTextFile('/dev/urandom').then((data) =>
|
const passwordHash = btoa('admin');
|
||||||
// Simple hash for now - should use bcrypt
|
|
||||||
btoa('admin')
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.database.createUser({
|
await this.database.createUser({
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
passwordHash: btoa('admin'), // Simple encoding for now
|
passwordHash,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -132,7 +129,7 @@ export class Onebox {
|
|||||||
async getSystemStatus() {
|
async getSystemStatus() {
|
||||||
try {
|
try {
|
||||||
const dockerRunning = await this.docker.isDockerRunning();
|
const dockerRunning = await this.docker.isDockerRunning();
|
||||||
const nginxStatus = await this.nginx.getStatus();
|
const proxyStatus = this.reverseProxy.getStatus();
|
||||||
const dnsConfigured = this.dns.isConfigured();
|
const dnsConfigured = this.dns.isConfigured();
|
||||||
const sslConfigured = this.ssl.isConfigured();
|
const sslConfigured = this.ssl.isConfigured();
|
||||||
|
|
||||||
@@ -145,10 +142,7 @@ export class Onebox {
|
|||||||
running: dockerRunning,
|
running: dockerRunning,
|
||||||
version: dockerRunning ? await this.docker.getDockerVersion() : null,
|
version: dockerRunning ? await this.docker.getDockerVersion() : null,
|
||||||
},
|
},
|
||||||
nginx: {
|
reverseProxy: proxyStatus,
|
||||||
status: nginxStatus,
|
|
||||||
installed: await this.nginx.isInstalled(),
|
|
||||||
},
|
|
||||||
dns: {
|
dns: {
|
||||||
configured: dnsConfigured,
|
configured: dnsConfigured,
|
||||||
},
|
},
|
||||||
@@ -209,6 +203,9 @@ export class Onebox {
|
|||||||
// Stop HTTP server if running
|
// Stop HTTP server if running
|
||||||
await this.httpServer.stop();
|
await this.httpServer.stop();
|
||||||
|
|
||||||
|
// Stop reverse proxy if running
|
||||||
|
await this.reverseProxy.stop();
|
||||||
|
|
||||||
// Close database
|
// Close database
|
||||||
this.database.close();
|
this.database.close();
|
||||||
|
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
* Manages Docker registry credentials and authentication
|
* Manages Docker registry credentials and authentication
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from './onebox.plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import type { IRegistry } from './onebox.types.ts';
|
import type { IRegistry } from '../types.ts';
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
import { OneboxDatabase } from './database.ts';
|
||||||
|
|
||||||
export class OneboxRegistriesManager {
|
export class OneboxRegistriesManager {
|
||||||
private oneboxRef: any; // Will be Onebox instance
|
private oneboxRef: any; // Will be Onebox instance
|
||||||
495
ts/classes/reverseproxy.ts
Normal file
495
ts/classes/reverseproxy.ts
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
/**
|
||||||
|
* Reverse Proxy for Onebox
|
||||||
|
*
|
||||||
|
* Native Deno HTTP/HTTPS reverse proxy with WebSocket support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../logging.ts';
|
||||||
|
import { OneboxDatabase } from './database.ts';
|
||||||
|
|
||||||
|
interface IProxyRoute {
|
||||||
|
domain: string;
|
||||||
|
targetHost: string;
|
||||||
|
targetPort: number;
|
||||||
|
serviceId: number;
|
||||||
|
containerID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ITlsConfig {
|
||||||
|
domain: string;
|
||||||
|
certPath: string;
|
||||||
|
keyPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OneboxReverseProxy {
|
||||||
|
private oneboxRef: any;
|
||||||
|
private database: OneboxDatabase;
|
||||||
|
private routes: Map<string, IProxyRoute> = new Map();
|
||||||
|
private httpServer: Deno.HttpServer | null = null;
|
||||||
|
private httpsServer: Deno.HttpServer | null = null;
|
||||||
|
private httpPort = 80;
|
||||||
|
private httpsPort = 443;
|
||||||
|
private tlsConfigs: Map<string, ITlsConfig> = new Map();
|
||||||
|
|
||||||
|
constructor(oneboxRef: any) {
|
||||||
|
this.oneboxRef = oneboxRef;
|
||||||
|
this.database = oneboxRef.database;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize reverse proxy
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info('Reverse proxy initialized');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to initialize reverse proxy: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the HTTP reverse proxy server
|
||||||
|
*/
|
||||||
|
async startHttp(port?: number): Promise<void> {
|
||||||
|
if (this.httpServer) {
|
||||||
|
logger.warn('HTTP reverse proxy already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port) {
|
||||||
|
this.httpPort = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Starting HTTP reverse proxy on port ${this.httpPort}...`);
|
||||||
|
|
||||||
|
this.httpServer = Deno.serve(
|
||||||
|
{
|
||||||
|
port: this.httpPort,
|
||||||
|
hostname: '0.0.0.0',
|
||||||
|
onListen: ({ hostname, port }) => {
|
||||||
|
logger.success(`HTTP reverse proxy listening on http://${hostname}:${port}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(req) => this.handleRequest(req, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.success(`HTTP reverse proxy started on port ${this.httpPort}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to start HTTP reverse proxy: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the HTTPS reverse proxy server
|
||||||
|
*/
|
||||||
|
async startHttps(port?: number): Promise<void> {
|
||||||
|
if (this.httpsServer) {
|
||||||
|
logger.warn('HTTPS reverse proxy already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port) {
|
||||||
|
this.httpsPort = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if we have any TLS configs
|
||||||
|
if (this.tlsConfigs.size === 0) {
|
||||||
|
logger.info('No TLS certificates configured, skipping HTTPS server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Starting HTTPS reverse proxy on port ${this.httpsPort}...`);
|
||||||
|
|
||||||
|
// Get the first certificate as default
|
||||||
|
const defaultConfig = Array.from(this.tlsConfigs.values())[0];
|
||||||
|
|
||||||
|
this.httpsServer = Deno.serve(
|
||||||
|
{
|
||||||
|
port: this.httpsPort,
|
||||||
|
hostname: '0.0.0.0',
|
||||||
|
cert: await Deno.readTextFile(defaultConfig.certPath),
|
||||||
|
key: await Deno.readTextFile(defaultConfig.keyPath),
|
||||||
|
onListen: ({ hostname, port }) => {
|
||||||
|
logger.success(`HTTPS reverse proxy listening on https://${hostname}:${port}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(req) => this.handleRequest(req, true)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.success(`HTTPS reverse proxy started on port ${this.httpsPort}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to start HTTPS reverse proxy: ${error.message}`);
|
||||||
|
// Don't throw - HTTPS is optional
|
||||||
|
logger.warn('Continuing without HTTPS support');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all reverse proxy servers
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
if (this.httpServer) {
|
||||||
|
promises.push(this.httpServer.shutdown());
|
||||||
|
this.httpServer = null;
|
||||||
|
logger.info('HTTP reverse proxy stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.httpsServer) {
|
||||||
|
promises.push(this.httpsServer.shutdown());
|
||||||
|
this.httpsServer = null;
|
||||||
|
logger.info('HTTPS reverse proxy stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming HTTP/HTTPS request
|
||||||
|
*/
|
||||||
|
private async handleRequest(req: Request, isHttps: boolean): Promise<Response> {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const host = req.headers.get('host')?.split(':')[0] || '';
|
||||||
|
|
||||||
|
logger.debug(`Proxy request: ${req.method} ${host}${url.pathname}`);
|
||||||
|
|
||||||
|
// Find matching route
|
||||||
|
const route = this.routes.get(host);
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
logger.debug(`No route found for host: ${host}`);
|
||||||
|
return new Response('Service not found', {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a WebSocket upgrade request
|
||||||
|
const upgrade = req.headers.get('upgrade')?.toLowerCase();
|
||||||
|
if (upgrade === 'websocket') {
|
||||||
|
return await this.handleWebSocketUpgrade(req, route, isHttps);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build target URL
|
||||||
|
const targetUrl = `http://${route.targetHost}:${route.targetPort}${url.pathname}${url.search}`;
|
||||||
|
|
||||||
|
logger.debug(`Proxying to: ${targetUrl}`);
|
||||||
|
|
||||||
|
// Forward request to target
|
||||||
|
const targetReq = new Request(targetUrl, {
|
||||||
|
method: req.method,
|
||||||
|
headers: this.forwardHeaders(req.headers, host, isHttps),
|
||||||
|
body: req.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(targetReq);
|
||||||
|
|
||||||
|
// Forward response back to client
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: this.filterResponseHeaders(response.headers),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Proxy error for ${host}: ${error.message}`);
|
||||||
|
return new Response('Bad Gateway', {
|
||||||
|
status: 502,
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle WebSocket upgrade and proxy connection
|
||||||
|
*/
|
||||||
|
private async handleWebSocketUpgrade(
|
||||||
|
req: Request,
|
||||||
|
route: IProxyRoute,
|
||||||
|
isHttps: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const targetUrl = `ws://${route.targetHost}:${route.targetPort}${url.pathname}${url.search}`;
|
||||||
|
|
||||||
|
logger.info(`WebSocket upgrade: ${url.host} -> ${targetUrl}`);
|
||||||
|
|
||||||
|
// Upgrade the client connection
|
||||||
|
const { socket: clientSocket, response } = Deno.upgradeWebSocket(req);
|
||||||
|
|
||||||
|
// Connect to backend WebSocket
|
||||||
|
const backendSocket = new WebSocket(targetUrl);
|
||||||
|
|
||||||
|
// Proxy messages from client to backend
|
||||||
|
clientSocket.onmessage = (e) => {
|
||||||
|
if (backendSocket.readyState === WebSocket.OPEN) {
|
||||||
|
backendSocket.send(e.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Proxy messages from backend to client
|
||||||
|
backendSocket.onmessage = (e) => {
|
||||||
|
if (clientSocket.readyState === WebSocket.OPEN) {
|
||||||
|
clientSocket.send(e.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle client close
|
||||||
|
clientSocket.onclose = () => {
|
||||||
|
logger.debug(`Client WebSocket closed for ${url.host}`);
|
||||||
|
backendSocket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle backend close
|
||||||
|
backendSocket.onclose = () => {
|
||||||
|
logger.debug(`Backend WebSocket closed for ${targetUrl}`);
|
||||||
|
clientSocket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
clientSocket.onerror = (e) => {
|
||||||
|
logger.error(`Client WebSocket error: ${e}`);
|
||||||
|
backendSocket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
backendSocket.onerror = (e) => {
|
||||||
|
logger.error(`Backend WebSocket error: ${e}`);
|
||||||
|
clientSocket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`WebSocket upgrade error: ${error.message}`);
|
||||||
|
return new Response('WebSocket Upgrade Failed', {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward request headers to target, filtering out problematic ones
|
||||||
|
*/
|
||||||
|
private forwardHeaders(headers: Headers, originalHost: string, isHttps: boolean): Headers {
|
||||||
|
const forwarded = new Headers();
|
||||||
|
|
||||||
|
// Copy most headers
|
||||||
|
for (const [key, value] of headers.entries()) {
|
||||||
|
// Skip headers that should not be forwarded
|
||||||
|
if (
|
||||||
|
key.toLowerCase() === 'host' ||
|
||||||
|
key.toLowerCase() === 'connection' ||
|
||||||
|
key.toLowerCase() === 'keep-alive' ||
|
||||||
|
key.toLowerCase() === 'proxy-authenticate' ||
|
||||||
|
key.toLowerCase() === 'proxy-authorization' ||
|
||||||
|
key.toLowerCase() === 'te' ||
|
||||||
|
key.toLowerCase() === 'trailers' ||
|
||||||
|
key.toLowerCase() === 'transfer-encoding' ||
|
||||||
|
key.toLowerCase() === 'upgrade'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarded.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add X-Forwarded headers
|
||||||
|
forwarded.set('X-Forwarded-For', headers.get('x-forwarded-for') || 'unknown');
|
||||||
|
forwarded.set('X-Forwarded-Host', originalHost);
|
||||||
|
forwarded.set('X-Forwarded-Proto', isHttps ? 'https' : 'http');
|
||||||
|
|
||||||
|
return forwarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter response headers
|
||||||
|
*/
|
||||||
|
private filterResponseHeaders(headers: Headers): Headers {
|
||||||
|
const filtered = new Headers();
|
||||||
|
|
||||||
|
for (const [key, value] of headers.entries()) {
|
||||||
|
// Skip problematic headers
|
||||||
|
if (
|
||||||
|
key.toLowerCase() === 'connection' ||
|
||||||
|
key.toLowerCase() === 'keep-alive' ||
|
||||||
|
key.toLowerCase() === 'transfer-encoding'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a route for a service
|
||||||
|
*/
|
||||||
|
async addRoute(serviceId: number, domain: string, targetPort: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get container IP from Docker
|
||||||
|
const service = this.database.getServiceByID(serviceId);
|
||||||
|
if (!service || !service.containerID) {
|
||||||
|
throw new Error(`Service not found or has no container: ${serviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Docker, we can use the container name or get its IP
|
||||||
|
// For now, use localhost since containers expose ports
|
||||||
|
const targetHost = 'localhost'; // TODO: Get actual container IP from Docker network
|
||||||
|
|
||||||
|
const route: IProxyRoute = {
|
||||||
|
domain,
|
||||||
|
targetHost,
|
||||||
|
targetPort,
|
||||||
|
serviceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.routes.set(domain, route);
|
||||||
|
logger.success(`Added proxy route: ${domain} -> ${targetHost}:${targetPort}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to add route for ${domain}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a route
|
||||||
|
*/
|
||||||
|
removeRoute(domain: string): void {
|
||||||
|
if (this.routes.delete(domain)) {
|
||||||
|
logger.success(`Removed proxy route: ${domain}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Route not found: ${domain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all routes
|
||||||
|
*/
|
||||||
|
getRoutes(): IProxyRoute[] {
|
||||||
|
return Array.from(this.routes.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload routes from database
|
||||||
|
*/
|
||||||
|
async reloadRoutes(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info('Reloading proxy routes...');
|
||||||
|
|
||||||
|
this.routes.clear();
|
||||||
|
|
||||||
|
const services = this.database.getAllServices();
|
||||||
|
|
||||||
|
for (const service of services) {
|
||||||
|
if (service.domain && service.status === 'running' && service.containerID) {
|
||||||
|
await this.addRoute(service.id!, service.domain, service.port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Loaded ${this.routes.size} proxy routes`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to reload routes: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add TLS certificate for a domain
|
||||||
|
*/
|
||||||
|
async addCertificate(domain: string, certPath: string, keyPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Verify certificate files exist
|
||||||
|
await Deno.stat(certPath);
|
||||||
|
await Deno.stat(keyPath);
|
||||||
|
|
||||||
|
this.tlsConfigs.set(domain, {
|
||||||
|
domain,
|
||||||
|
certPath,
|
||||||
|
keyPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.success(`Added TLS certificate for ${domain}`);
|
||||||
|
|
||||||
|
// If HTTPS server is already running, we need to restart it
|
||||||
|
// TODO: Implement hot reload for certificates
|
||||||
|
if (this.httpsServer) {
|
||||||
|
logger.warn('HTTPS server restart required for new certificate to take effect');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to add certificate for ${domain}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove TLS certificate for a domain
|
||||||
|
*/
|
||||||
|
removeCertificate(domain: string): void {
|
||||||
|
if (this.tlsConfigs.delete(domain)) {
|
||||||
|
logger.success(`Removed TLS certificate for ${domain}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Certificate not found for domain: ${domain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload TLS certificates from SSL manager
|
||||||
|
*/
|
||||||
|
async reloadCertificates(): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info('Reloading TLS certificates...');
|
||||||
|
|
||||||
|
this.tlsConfigs.clear();
|
||||||
|
|
||||||
|
const certificates = this.database.getAllSSLCertificates();
|
||||||
|
|
||||||
|
for (const cert of certificates) {
|
||||||
|
if (cert.domain && cert.certPath && cert.keyPath) {
|
||||||
|
try {
|
||||||
|
await this.addCertificate(cert.domain, cert.fullChainPath, cert.keyPath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to load certificate for ${cert.domain}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Loaded ${this.tlsConfigs.size} TLS certificates`);
|
||||||
|
|
||||||
|
// Restart HTTPS server if it was running
|
||||||
|
if (this.httpsServer) {
|
||||||
|
logger.info('Restarting HTTPS server with new certificates...');
|
||||||
|
await this.httpsServer.shutdown();
|
||||||
|
this.httpsServer = null;
|
||||||
|
await this.startHttps();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to reload certificates: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status of reverse proxy
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
http: {
|
||||||
|
running: this.httpServer !== null,
|
||||||
|
port: this.httpPort,
|
||||||
|
},
|
||||||
|
https: {
|
||||||
|
running: this.httpsServer !== null,
|
||||||
|
port: this.httpsPort,
|
||||||
|
certificates: this.tlsConfigs.size,
|
||||||
|
},
|
||||||
|
routes: this.routes.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
* Orchestrates service deployment: Docker + Nginx + DNS + SSL
|
* Orchestrates service deployment: Docker + Nginx + DNS + SSL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IService, IServiceDeployOptions } from './onebox.types.ts';
|
import type { IService, IServiceDeployOptions } from '../types.ts';
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
import { OneboxDatabase } from './database.ts';
|
||||||
import { OneboxDockerManager } from './onebox.classes.docker.ts';
|
import { OneboxDockerManager } from './docker.ts';
|
||||||
|
|
||||||
export class OneboxServicesManager {
|
export class OneboxServicesManager {
|
||||||
private oneboxRef: any; // Will be Onebox instance
|
private oneboxRef: any; // Will be Onebox instance
|
||||||
@@ -77,19 +77,18 @@ export class OneboxServicesManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure nginx
|
// Configure reverse proxy
|
||||||
try {
|
try {
|
||||||
await this.oneboxRef.nginx.createConfig(service.id!, options.domain, options.port);
|
await this.oneboxRef.reverseProxy.addRoute(service.id!, options.domain, options.port);
|
||||||
await this.oneboxRef.nginx.reload();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to configure Nginx for ${options.domain}: ${error.message}`);
|
logger.warn(`Failed to configure reverse proxy for ${options.domain}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure SSL (if autoSSL is enabled)
|
// Configure SSL (if autoSSL is enabled)
|
||||||
if (options.autoSSL !== false) {
|
if (options.autoSSL !== false) {
|
||||||
try {
|
try {
|
||||||
await this.oneboxRef.ssl.obtainCertificate(options.domain);
|
await this.oneboxRef.ssl.obtainCertificate(options.domain);
|
||||||
await this.oneboxRef.nginx.reload();
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to obtain SSL certificate for ${options.domain}: ${error.message}`);
|
logger.warn(`Failed to obtain SSL certificate for ${options.domain}: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -215,13 +214,12 @@ export class OneboxServicesManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove nginx config
|
// Remove reverse proxy route
|
||||||
if (service.domain) {
|
if (service.domain) {
|
||||||
try {
|
try {
|
||||||
await this.oneboxRef.nginx.removeConfig(service.id!);
|
this.oneboxRef.reverseProxy.removeRoute(service.domain);
|
||||||
await this.oneboxRef.nginx.reload();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to remove Nginx config: ${error.message}`);
|
logger.warn(`Failed to remove reverse proxy route: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: We don't remove DNS records or SSL certs automatically
|
// Note: We don't remove DNS records or SSL certs automatically
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
* Manages SSL certificates via Let's Encrypt (using smartacme)
|
* Manages SSL certificates via Let's Encrypt (using smartacme)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as plugins from './onebox.plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
import { OneboxDatabase } from './database.ts';
|
||||||
|
|
||||||
export class OneboxSslManager {
|
export class OneboxSslManager {
|
||||||
private oneboxRef: any;
|
private oneboxRef: any;
|
||||||
@@ -106,8 +106,8 @@ export class OneboxSslManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable SSL in nginx config
|
// Reload certificates in reverse proxy
|
||||||
await this.oneboxRef.nginx.enableSSL(domain);
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
|
|
||||||
logger.success(`SSL certificate obtained for ${domain}`);
|
logger.success(`SSL certificate obtained for ${domain}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -182,8 +182,8 @@ export class OneboxSslManager {
|
|||||||
|
|
||||||
logger.success(`Certificate renewed for ${domain}`);
|
logger.success(`Certificate renewed for ${domain}`);
|
||||||
|
|
||||||
// Reload nginx
|
// Reload certificates in reverse proxy
|
||||||
await this.oneboxRef.nginx.reload();
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
|
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -256,8 +256,8 @@ export class OneboxSslManager {
|
|||||||
|
|
||||||
logger.success('All certificates renewed');
|
logger.success('All certificates renewed');
|
||||||
|
|
||||||
// Reload nginx
|
// Reload certificates in reverse proxy
|
||||||
await this.oneboxRef.nginx.reload();
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to renew all certificates: ${error.message}`);
|
logger.error(`Failed to renew all certificates: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
* CLI Router for Onebox
|
* CLI Router for Onebox
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { logger } from './onebox.logging.ts';
|
import { logger } from './logging.ts';
|
||||||
import { projectInfo } from './onebox.info.ts';
|
import { projectInfo } from './info.ts';
|
||||||
import { Onebox } from './onebox.classes.onebox.ts';
|
import { Onebox } from './classes/onebox.ts';
|
||||||
|
import { OneboxDaemon } from './classes/daemon.ts';
|
||||||
|
|
||||||
export async function runCli(): Promise<void> {
|
export async function runCli(): Promise<void> {
|
||||||
const args = Deno.args;
|
const args = Deno.args;
|
||||||
@@ -23,6 +24,15 @@ export async function runCli(): Promise<void> {
|
|||||||
const subcommand = args[1];
|
const subcommand = args[1];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Server command has special handling (doesn't shut down)
|
||||||
|
if (command === 'server') {
|
||||||
|
const onebox = new Onebox();
|
||||||
|
await onebox.init();
|
||||||
|
await handleServerCommand(onebox, args.slice(1));
|
||||||
|
// Server command runs forever (or until Ctrl+C), so this never returns
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Onebox
|
// Initialize Onebox
|
||||||
const onebox = new Onebox();
|
const onebox = new Onebox();
|
||||||
await onebox.init();
|
await onebox.init();
|
||||||
@@ -239,6 +249,62 @@ async function handleNginxCommand(onebox: Onebox, subcommand: string, _args: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server command
|
||||||
|
async function handleServerCommand(onebox: Onebox, args: string[]) {
|
||||||
|
const ephemeral = args.includes('--ephemeral');
|
||||||
|
const port = parseInt(getArg(args, '--port') || '3000', 10);
|
||||||
|
const monitor = args.includes('--monitor') || ephemeral; // ephemeral includes monitoring
|
||||||
|
|
||||||
|
if (ephemeral) {
|
||||||
|
// Ensure no daemon is running
|
||||||
|
try {
|
||||||
|
await OneboxDaemon.ensureNoDaemon();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Cannot start in ephemeral mode: Daemon is already running');
|
||||||
|
logger.info('Stop the daemon first: onebox daemon stop');
|
||||||
|
logger.info('Or run without --ephemeral to use the existing daemon');
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Starting Onebox server...');
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
|
await onebox.httpServer.start(port);
|
||||||
|
|
||||||
|
// Start monitoring if requested
|
||||||
|
if (monitor) {
|
||||||
|
logger.info('Starting monitoring loop...');
|
||||||
|
onebox.daemon.startMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Onebox server running on http://localhost:${port}`);
|
||||||
|
if (ephemeral) {
|
||||||
|
logger.info('Running in ephemeral mode - Press Ctrl+C to stop');
|
||||||
|
} else {
|
||||||
|
logger.info('Press Ctrl+C to stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup signal handlers
|
||||||
|
const shutdown = async () => {
|
||||||
|
logger.info('Shutting down...');
|
||||||
|
if (monitor) {
|
||||||
|
onebox.daemon.stopMonitoring();
|
||||||
|
}
|
||||||
|
await onebox.httpServer.stop();
|
||||||
|
await onebox.shutdown();
|
||||||
|
Deno.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.addSignalListener('SIGINT', shutdown);
|
||||||
|
Deno.addSignalListener('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
// Keep alive
|
||||||
|
while (true) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Daemon commands
|
// Daemon commands
|
||||||
async function handleDaemonCommand(onebox: Onebox, subcommand: string, _args: string[]) {
|
async function handleDaemonCommand(onebox: Onebox, subcommand: string, _args: string[]) {
|
||||||
switch (subcommand) {
|
switch (subcommand) {
|
||||||
@@ -316,6 +382,12 @@ Onebox v${projectInfo.version} - Self-hosted container platform
|
|||||||
Usage: onebox <command> [options]
|
Usage: onebox <command> [options]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
server [--ephemeral] [--port <port>] [--monitor]
|
||||||
|
Start HTTP server
|
||||||
|
--ephemeral: Run in foreground (development mode, checks no daemon running)
|
||||||
|
--port: HTTP server port (default: 3000)
|
||||||
|
--monitor: Enable monitoring loop (included with --ephemeral)
|
||||||
|
|
||||||
service add <name> --image <image> [--domain <domain>] [--port <port>] [--env KEY=VALUE]
|
service add <name> --image <image> [--domain <domain>] [--port <port>] [--env KEY=VALUE]
|
||||||
service remove <name>
|
service remove <name>
|
||||||
service start <name>
|
service start <name>
|
||||||
@@ -357,7 +429,17 @@ Options:
|
|||||||
--version, -v Show version
|
--version, -v Show version
|
||||||
--debug Enable debug logging
|
--debug Enable debug logging
|
||||||
|
|
||||||
|
Development Workflow:
|
||||||
|
deno task dev # Start ephemeral server with monitoring
|
||||||
|
onebox service add ... # In another terminal
|
||||||
|
|
||||||
|
Production Workflow:
|
||||||
|
onebox daemon install # Install systemd service
|
||||||
|
onebox daemon start # Start daemon
|
||||||
|
onebox service add ... # CLI uses daemon
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
onebox server --ephemeral # Start dev server
|
||||||
onebox service add myapp --image nginx:latest --domain app.example.com --port 80
|
onebox service add myapp --image nginx:latest --domain app.example.com --port 80
|
||||||
onebox registry add --url registry.example.com --username user --password pass
|
onebox registry add --url registry.example.com --username user --password pass
|
||||||
onebox daemon install
|
onebox daemon install
|
||||||
29
ts/index.ts
29
ts/index.ts
@@ -2,23 +2,24 @@
|
|||||||
* Main exports and CLI entry point for Onebox
|
* Main exports and CLI entry point for Onebox
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { Onebox } from './onebox.classes.onebox.ts';
|
export { Onebox } from './classes/onebox.ts';
|
||||||
export { runCli } from './onebox.cli.ts';
|
export { runCli } from './cli.ts';
|
||||||
export { OneboxDatabase } from './onebox.classes.database.ts';
|
export { OneboxDatabase } from './classes/database.ts';
|
||||||
export { OneboxDockerManager } from './onebox.classes.docker.ts';
|
export { OneboxDockerManager } from './classes/docker.ts';
|
||||||
export { OneboxServicesManager } from './onebox.classes.services.ts';
|
export { OneboxServicesManager } from './classes/services.ts';
|
||||||
export { OneboxRegistriesManager } from './onebox.classes.registries.ts';
|
export { OneboxRegistriesManager } from './classes/registries.ts';
|
||||||
export { OneboxNginxManager } from './onebox.classes.nginx.ts';
|
export { OneboxReverseProxy } from './classes/reverseproxy.ts';
|
||||||
export { OneboxDnsManager } from './onebox.classes.dns.ts';
|
export { OneboxDnsManager } from './classes/dns.ts';
|
||||||
export { OneboxSslManager } from './onebox.classes.ssl.ts';
|
export { OneboxSslManager } from './classes/ssl.ts';
|
||||||
export { OneboxDaemon } from './onebox.classes.daemon.ts';
|
export { OneboxDaemon } from './classes/daemon.ts';
|
||||||
export { OneboxHttpServer } from './onebox.classes.httpserver.ts';
|
export { OneboxHttpServer } from './classes/httpserver.ts';
|
||||||
|
export { OneboxApiClient } from './classes/apiclient.ts';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export * from './onebox.types.ts';
|
export * from './types.ts';
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
export { logger } from './onebox.logging.ts';
|
export { logger } from './logging.ts';
|
||||||
|
|
||||||
// Version info
|
// Version info
|
||||||
export { projectInfo } from './onebox.info.ts';
|
export { projectInfo } from './info.ts';
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
/**
|
|
||||||
* Nginx Manager for Onebox
|
|
||||||
*
|
|
||||||
* Manages Nginx reverse proxy configurations for services
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as plugins from './onebox.plugins.ts';
|
|
||||||
import { logger } from './onebox.logging.ts';
|
|
||||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
|
||||||
|
|
||||||
export class OneboxNginxManager {
|
|
||||||
private oneboxRef: any;
|
|
||||||
private database: OneboxDatabase;
|
|
||||||
private configDir = '/etc/nginx/sites-available';
|
|
||||||
private enabledDir = '/etc/nginx/sites-enabled';
|
|
||||||
|
|
||||||
constructor(oneboxRef: any) {
|
|
||||||
this.oneboxRef = oneboxRef;
|
|
||||||
this.database = oneboxRef.database;
|
|
||||||
|
|
||||||
// Allow custom nginx config directory
|
|
||||||
const customDir = this.database.getSetting('nginxConfigDir');
|
|
||||||
if (customDir) {
|
|
||||||
this.configDir = customDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize nginx manager
|
|
||||||
*/
|
|
||||||
async init(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Ensure directories exist
|
|
||||||
await Deno.mkdir(this.configDir, { recursive: true });
|
|
||||||
await Deno.mkdir(this.enabledDir, { recursive: true });
|
|
||||||
|
|
||||||
logger.info('Nginx manager initialized');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to initialize Nginx manager: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create nginx config for a service
|
|
||||||
*/
|
|
||||||
async createConfig(serviceId: number, domain: string, port: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
logger.info(`Creating Nginx config for ${domain}`);
|
|
||||||
|
|
||||||
const service = this.database.getServiceByID(serviceId);
|
|
||||||
if (!service) {
|
|
||||||
throw new Error(`Service not found: ${serviceId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get container IP (or use container name for DNS resolution within Docker network)
|
|
||||||
const containerName = `onebox-${service.name}`;
|
|
||||||
|
|
||||||
// Generate config
|
|
||||||
const config = this.generateConfig(domain, containerName, port);
|
|
||||||
|
|
||||||
// Write config file
|
|
||||||
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
|
|
||||||
await Deno.writeTextFile(configPath, config);
|
|
||||||
|
|
||||||
// Create symlink in sites-enabled
|
|
||||||
const enabledPath = `${this.enabledDir}/onebox-${service.name}.conf`;
|
|
||||||
try {
|
|
||||||
await Deno.remove(enabledPath);
|
|
||||||
} catch {
|
|
||||||
// Ignore if doesn't exist
|
|
||||||
}
|
|
||||||
await Deno.symlink(configPath, enabledPath);
|
|
||||||
|
|
||||||
logger.success(`Nginx config created: ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to create Nginx config: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate nginx configuration
|
|
||||||
*/
|
|
||||||
private generateConfig(domain: string, upstream: string, port: number): string {
|
|
||||||
return `# Onebox-managed configuration for ${domain}
|
|
||||||
# Generated at ${new Date().toISOString()}
|
|
||||||
|
|
||||||
upstream onebox_${domain.replace(/\./g, '_')} {
|
|
||||||
server ${upstream}:${port};
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTP server (redirect to HTTPS or serve directly)
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name ${domain};
|
|
||||||
|
|
||||||
# ACME challenge for Let's Encrypt
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/certbot;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Redirect to HTTPS (will be enabled after SSL is configured)
|
|
||||||
# location / {
|
|
||||||
# return 301 https://$server_name$request_uri;
|
|
||||||
# }
|
|
||||||
|
|
||||||
# Proxy to container (remove after SSL is configured)
|
|
||||||
location / {
|
|
||||||
proxy_pass http://onebox_${domain.replace(/\./g, '_')};
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# WebSocket support
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
|
|
||||||
# Timeouts
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 60s;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTPS server (uncomment after SSL is configured)
|
|
||||||
# server {
|
|
||||||
# listen 443 ssl http2;
|
|
||||||
# listen [::]:443 ssl http2;
|
|
||||||
# server_name ${domain};
|
|
||||||
#
|
|
||||||
# ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
|
|
||||||
# ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
|
|
||||||
#
|
|
||||||
# # SSL configuration
|
|
||||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
|
||||||
# ssl_prefer_server_ciphers on;
|
|
||||||
#
|
|
||||||
# location / {
|
|
||||||
# proxy_pass http://onebox_${domain.replace(/\./g, '_')};
|
|
||||||
# proxy_set_header Host $host;
|
|
||||||
# proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
#
|
|
||||||
# # WebSocket support
|
|
||||||
# proxy_http_version 1.1;
|
|
||||||
# proxy_set_header Upgrade $http_upgrade;
|
|
||||||
# proxy_set_header Connection "upgrade";
|
|
||||||
#
|
|
||||||
# # Timeouts
|
|
||||||
# proxy_connect_timeout 60s;
|
|
||||||
# proxy_send_timeout 60s;
|
|
||||||
# proxy_read_timeout 60s;
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update nginx config to enable SSL
|
|
||||||
*/
|
|
||||||
async enableSSL(domain: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
logger.info(`Enabling SSL for ${domain}`);
|
|
||||||
|
|
||||||
// Find service by domain
|
|
||||||
const services = this.database.getAllServices();
|
|
||||||
const service = services.find((s) => s.domain === domain);
|
|
||||||
|
|
||||||
if (!service) {
|
|
||||||
throw new Error(`Service not found for domain: ${domain}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
|
|
||||||
let config = await Deno.readTextFile(configPath);
|
|
||||||
|
|
||||||
// Enable HTTPS redirect and HTTPS server block
|
|
||||||
config = config.replace('# location / {\n # return 301', 'location / {\n return 301');
|
|
||||||
config = config.replace('# }', '}');
|
|
||||||
config = config.replace(/# server \{[\s\S]*?# \}/m, (match) =>
|
|
||||||
match.replace(/# /g, '')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Comment out HTTP proxy location
|
|
||||||
config = config.replace(
|
|
||||||
/# Proxy to container \(remove after SSL is configured\)[\s\S]*?location \/ \{[\s\S]*?\n \}/,
|
|
||||||
(match) => `# ${match.replace(/\n/g, '\n # ')}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await Deno.writeTextFile(configPath, config);
|
|
||||||
|
|
||||||
logger.success(`SSL enabled for ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to enable SSL for ${domain}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove nginx config for a service
|
|
||||||
*/
|
|
||||||
async removeConfig(serviceId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const service = this.database.getServiceByID(serviceId);
|
|
||||||
if (!service) {
|
|
||||||
throw new Error(`Service not found: ${serviceId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Removing Nginx config for ${service.name}`);
|
|
||||||
|
|
||||||
// Remove symlink
|
|
||||||
const enabledPath = `${this.enabledDir}/onebox-${service.name}.conf`;
|
|
||||||
try {
|
|
||||||
await Deno.remove(enabledPath);
|
|
||||||
} catch {
|
|
||||||
// Ignore if doesn't exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove config file
|
|
||||||
const configPath = `${this.configDir}/onebox-${service.name}.conf`;
|
|
||||||
try {
|
|
||||||
await Deno.remove(configPath);
|
|
||||||
} catch {
|
|
||||||
// Ignore if doesn't exist
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.success(`Nginx config removed for ${service.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to remove Nginx config: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test nginx configuration
|
|
||||||
*/
|
|
||||||
async test(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const command = new Deno.Command('nginx', {
|
|
||||||
args: ['-t'],
|
|
||||||
stdout: 'piped',
|
|
||||||
stderr: 'piped',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { code, stderr } = await command.output();
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
const errorMsg = new TextDecoder().decode(stderr);
|
|
||||||
logger.error(`Nginx config test failed: ${errorMsg}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.success('Nginx configuration is valid');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to test Nginx config: ${error.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reload nginx
|
|
||||||
*/
|
|
||||||
async reload(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Test config first
|
|
||||||
const isValid = await this.test();
|
|
||||||
if (!isValid) {
|
|
||||||
throw new Error('Nginx configuration is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Reloading Nginx...');
|
|
||||||
|
|
||||||
const command = new Deno.Command('systemctl', {
|
|
||||||
args: ['reload', 'nginx'],
|
|
||||||
stdout: 'piped',
|
|
||||||
stderr: 'piped',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { code, stderr } = await command.output();
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
const errorMsg = new TextDecoder().decode(stderr);
|
|
||||||
throw new Error(`Nginx reload failed: ${errorMsg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.success('Nginx reloaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to reload Nginx: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get nginx status
|
|
||||||
*/
|
|
||||||
async getStatus(): Promise<string> {
|
|
||||||
try {
|
|
||||||
const command = new Deno.Command('systemctl', {
|
|
||||||
args: ['status', 'nginx'],
|
|
||||||
stdout: 'piped',
|
|
||||||
stderr: 'piped',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { code, stdout } = await command.output();
|
|
||||||
const output = new TextDecoder().decode(stdout);
|
|
||||||
|
|
||||||
if (code === 0 || output.includes('active (running)')) {
|
|
||||||
return 'running';
|
|
||||||
} else if (output.includes('inactive') || output.includes('dead')) {
|
|
||||||
return 'stopped';
|
|
||||||
} else if (output.includes('failed')) {
|
|
||||||
return 'failed';
|
|
||||||
} else {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to get Nginx status: ${error.message}`);
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if nginx is installed
|
|
||||||
*/
|
|
||||||
async isInstalled(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const command = new Deno.Command('which', {
|
|
||||||
args: ['nginx'],
|
|
||||||
stdout: 'piped',
|
|
||||||
stderr: 'piped',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { code } = await command.output();
|
|
||||||
return code === 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,16 +14,16 @@ import * as encoding from '@std/encoding';
|
|||||||
export { path, fs, http, encoding };
|
export { path, fs, http, encoding };
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
import * as sqlite from '@db/sqlite';
|
import { Database } from '@db/sqlite';
|
||||||
export { sqlite };
|
export const sqlite = { DB: Database };
|
||||||
|
|
||||||
// Systemd Daemon Integration
|
// Systemd Daemon Integration
|
||||||
import * as smartdaemon from '@push.rocks/smartdaemon';
|
import * as smartdaemon from '@push.rocks/smartdaemon';
|
||||||
export { smartdaemon };
|
export { smartdaemon };
|
||||||
|
|
||||||
// Docker API Client
|
// Docker API Client
|
||||||
import * as docker from '@apiclient.xyz/docker';
|
import { DockerHost } from '@apiclient.xyz/docker';
|
||||||
export { docker };
|
export const docker = { Docker: DockerHost };
|
||||||
|
|
||||||
// Cloudflare DNS Management
|
// Cloudflare DNS Management
|
||||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||||
@@ -38,8 +38,8 @@ import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
|
|||||||
export { bcrypt };
|
export { bcrypt };
|
||||||
|
|
||||||
// JWT for authentication
|
// JWT for authentication
|
||||||
import { create as createJwt, verify as verifyJwt, decode as decodeJwt } from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
|
import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
|
||||||
export { createJwt, verifyJwt, decodeJwt };
|
export { jwt};
|
||||||
|
|
||||||
// Crypto key management
|
// Crypto key management
|
||||||
import { crypto } from 'https://deno.land/std@0.208.0/crypto/mod.ts';
|
import { crypto } from 'https://deno.land/std@0.208.0/crypto/mod.ts';
|
||||||
14
ui/.gitignore
vendored
Normal file
14
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
.angular/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
77
ui/angular.json
Normal file
77
ui/angular.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"onebox-ui": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "css",
|
||||||
|
"standalone": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:browser",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"main": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets"
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "500kb",
|
||||||
|
"maximumError": "1mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "2kb",
|
||||||
|
"maximumError": "4kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildOptimizer": false,
|
||||||
|
"optimization": false,
|
||||||
|
"vendorChunk": true,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"namedChunks": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "onebox-ui:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "onebox-ui:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
ui/package.json
Normal file
37
ui/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "onebox-ui",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
"build": "ng build --configuration production",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^18.0.0",
|
||||||
|
"@angular/common": "^18.0.0",
|
||||||
|
"@angular/compiler": "^18.0.0",
|
||||||
|
"@angular/core": "^18.0.0",
|
||||||
|
"@angular/forms": "^18.0.0",
|
||||||
|
"@angular/platform-browser": "^18.0.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^18.0.0",
|
||||||
|
"@angular/router": "^18.0.0",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"ng2-charts": "^6.0.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.14.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^18.0.0",
|
||||||
|
"@angular/cli": "^18.0.0",
|
||||||
|
"@angular/compiler-cli": "^18.0.0",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"typescript": "~5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
8220
ui/pnpm-lock.yaml
generated
Normal file
8220
ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
ui/proxy.conf.json
Normal file
7
ui/proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:3000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ui/src/app/app.component.ts
Normal file
10
ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet],
|
||||||
|
template: `<router-outlet></router-outlet>`,
|
||||||
|
})
|
||||||
|
export class AppComponent {}
|
||||||
71
ui/src/app/app.routes.ts
Normal file
71
ui/src/app/app.routes.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { authGuard } from './core/guards/auth.guard';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/login/login.component').then((m) => m.LoginComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./shared/components/layout.component').then((m) => m.LayoutComponent),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirectTo: 'dashboard',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'services',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/services/services-list.component').then(
|
||||||
|
(m) => m.ServicesListComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'services/new',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/services/service-create.component').then(
|
||||||
|
(m) => m.ServiceCreateComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'services/:name',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/services/service-detail.component').then(
|
||||||
|
(m) => m.ServiceDetailComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'registries',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/registries/registries.component').then(
|
||||||
|
(m) => m.RegistriesComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dns',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/dns/dns.component').then((m) => m.DnsComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'ssl',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/ssl/ssl.component').then((m) => m.SslComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/settings/settings.component').then((m) => m.SettingsComponent),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
15
ui/src/app/core/guards/auth.guard.ts
Normal file
15
ui/src/app/core/guards/auth.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Router, CanActivateFn } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
export const authGuard: CanActivateFn = () => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
if (authService.isAuthenticated()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.navigate(['/login']);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
18
ui/src/app/core/interceptors/auth.interceptor.ts
Normal file
18
ui/src/app/core/interceptors/auth.interceptor.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const token = authService.getToken();
|
||||||
|
|
||||||
|
if (token && !req.url.includes('/api/auth/login')) {
|
||||||
|
req = req.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(req);
|
||||||
|
};
|
||||||
144
ui/src/app/core/services/api.service.ts
Normal file
144
ui/src/app/core/services/api.service.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
registry?: string;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
port: number;
|
||||||
|
domain?: string;
|
||||||
|
containerID?: string;
|
||||||
|
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Registry {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
docker: {
|
||||||
|
running: boolean;
|
||||||
|
version: any;
|
||||||
|
};
|
||||||
|
nginx: {
|
||||||
|
status: string;
|
||||||
|
installed: boolean;
|
||||||
|
};
|
||||||
|
dns: {
|
||||||
|
configured: boolean;
|
||||||
|
};
|
||||||
|
ssl: {
|
||||||
|
configured: boolean;
|
||||||
|
certbotInstalled: boolean;
|
||||||
|
};
|
||||||
|
services: {
|
||||||
|
total: number;
|
||||||
|
running: number;
|
||||||
|
stopped: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class ApiService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private baseUrl = '/api';
|
||||||
|
|
||||||
|
// System
|
||||||
|
getStatus(): Observable<ApiResponse<SystemStatus>> {
|
||||||
|
return this.http.get<ApiResponse<SystemStatus>>(`${this.baseUrl}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Services
|
||||||
|
getServices(): Observable<ApiResponse<Service[]>> {
|
||||||
|
return this.http.get<ApiResponse<Service[]>>(`${this.baseUrl}/services`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getService(name: string): Observable<ApiResponse<Service>> {
|
||||||
|
return this.http.get<ApiResponse<Service>>(`${this.baseUrl}/services/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createService(data: any): Observable<ApiResponse<Service>> {
|
||||||
|
return this.http.post<ApiResponse<Service>>(`${this.baseUrl}/services`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteService(name: string): Observable<ApiResponse> {
|
||||||
|
return this.http.delete<ApiResponse>(`${this.baseUrl}/services/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
startService(name: string): Observable<ApiResponse> {
|
||||||
|
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/start`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopService(name: string): Observable<ApiResponse> {
|
||||||
|
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/stop`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
restartService(name: string): Observable<ApiResponse> {
|
||||||
|
return this.http.post<ApiResponse>(`${this.baseUrl}/services/${name}/restart`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
getServiceLogs(name: string): Observable<ApiResponse<string>> {
|
||||||
|
return this.http.get<ApiResponse<string>>(`${this.baseUrl}/services/${name}/logs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registries
|
||||||
|
getRegistries(): Observable<ApiResponse<Registry[]>> {
|
||||||
|
return this.http.get<ApiResponse<Registry[]>>(`${this.baseUrl}/registries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRegistry(data: any): Observable<ApiResponse<Registry>> {
|
||||||
|
return this.http.post<ApiResponse<Registry>>(`${this.baseUrl}/registries`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRegistry(url: string): Observable<ApiResponse> {
|
||||||
|
return this.http.delete<ApiResponse>(`${this.baseUrl}/registries/${encodeURIComponent(url)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS
|
||||||
|
getDnsRecords(): Observable<ApiResponse<any[]>> {
|
||||||
|
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/dns`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDnsRecord(data: any): Observable<ApiResponse> {
|
||||||
|
return this.http.post<ApiResponse>(`${this.baseUrl}/dns`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDnsRecord(domain: string): Observable<ApiResponse> {
|
||||||
|
return this.http.delete<ApiResponse>(`${this.baseUrl}/dns/${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSL
|
||||||
|
getSslCertificates(): Observable<ApiResponse<any[]>> {
|
||||||
|
return this.http.get<ApiResponse<any[]>>(`${this.baseUrl}/ssl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
renewSslCertificate(domain: string): Observable<ApiResponse> {
|
||||||
|
return this.http.post<ApiResponse>(`${this.baseUrl}/ssl/${domain}/renew`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
getSettings(): Observable<ApiResponse<Record<string, string>>> {
|
||||||
|
return this.http.get<ApiResponse<Record<string, string>>>(`${this.baseUrl}/settings`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSetting(key: string, value: string): Observable<ApiResponse> {
|
||||||
|
return this.http.post<ApiResponse>(`${this.baseUrl}/settings`, { key, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
69
ui/src/app/core/services/auth.service.ts
Normal file
69
ui/src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Observable, tap } from 'rxjs';
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
token: string;
|
||||||
|
user: {
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
isAuthenticated = signal(false);
|
||||||
|
currentUser = signal<{ username: string; role: string } | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Check if already authenticated
|
||||||
|
const token = this.getToken();
|
||||||
|
if (token) {
|
||||||
|
this.isAuthenticated.set(true);
|
||||||
|
// TODO: Decode JWT to get user info
|
||||||
|
this.currentUser.set({ username: 'admin', role: 'admin' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
login(credentials: LoginRequest): Observable<LoginResponse> {
|
||||||
|
return this.http.post<LoginResponse>('/api/auth/login', credentials).pipe(
|
||||||
|
tap((response) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.setToken(response.data.token);
|
||||||
|
this.currentUser.set(response.data.user);
|
||||||
|
this.isAuthenticated.set(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
localStorage.removeItem('onebox_token');
|
||||||
|
this.isAuthenticated.set(false);
|
||||||
|
this.currentUser.set(null);
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(): string | null {
|
||||||
|
return localStorage.getItem('onebox_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
private setToken(token: string): void {
|
||||||
|
localStorage.setItem('onebox_token', token);
|
||||||
|
}
|
||||||
|
}
|
||||||
192
ui/src/app/features/dashboard/dashboard.component.ts
Normal file
192
ui/src/app/features/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { ApiService, SystemStatus } from '../../core/services/api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink],
|
||||||
|
template: `
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-8">Dashboard</h1>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
} @else if (status()) {
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||||
|
<!-- Total Services -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0 bg-primary-500 rounded-md p-3">
|
||||||
|
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Total Services</dt>
|
||||||
|
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.total }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Running Services -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0 bg-green-500 rounded-md p-3">
|
||||||
|
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Running</dt>
|
||||||
|
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.running }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stopped Services -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0 bg-gray-500 rounded-md p-3">
|
||||||
|
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Stopped</dt>
|
||||||
|
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.stopped }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Docker Status -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0 rounded-md p-3" [ngClass]="status()!.docker.running ? 'bg-green-500' : 'bg-red-500'">
|
||||||
|
<svg class="h-6 w-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338 0-.676.03-1.01.09-.458-1.314-1.605-2.16-2.898-2.16h-.048c-.328 0-.654.06-.969.18-.618-2.066-2.215-3.073-4.752-3.073-2.538 0-4.135 1.007-4.753 3.073-.315-.12-.64-.18-.969-.18h-.048c-1.293 0-2.44.846-2.898 2.16a8.39 8.39 0 00-1.01-.09c-1.282 0-1.889.459-1.954.51L0 10.2l.08.31s.935 3.605 4.059 4.794v.003c.563.215 1.156.322 1.756.322.71 0 1.423-.129 2.112-.385a8.804 8.804 0 002.208.275c.877 0 1.692-.165 2.411-.49a4.71 4.71 0 001.617.28c.606 0 1.201-.11 1.773-.328.572.219 1.167.327 1.772.327.71 0 1.423-.129 2.112-.385.79.251 1.57.376 2.315.376.606 0 1.2-.107 1.766-.322v-.003c3.124-1.189 4.059-4.794 4.059-4.794l.08-.31-.237-.31z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Docker</dt>
|
||||||
|
<dd class="text-lg font-semibold text-gray-900">
|
||||||
|
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Status -->
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<!-- Docker -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Docker</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Status</span>
|
||||||
|
<span [ngClass]="status()!.docker.running ? 'badge-success' : 'badge-danger'" class="badge">
|
||||||
|
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@if (status()!.docker.version) {
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Version</span>
|
||||||
|
<span class="text-sm text-gray-900">{{ status()!.docker.version.Version }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nginx -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Nginx</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Status</span>
|
||||||
|
<span [ngClass]="status()!.nginx.status === 'running' ? 'badge-success' : 'badge-danger'" class="badge">
|
||||||
|
{{ status()!.nginx.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Installed</span>
|
||||||
|
<span [ngClass]="status()!.nginx.installed ? 'badge-success' : 'badge-danger'" class="badge">
|
||||||
|
{{ status()!.nginx.installed ? 'Yes' : 'No' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DNS & SSL -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">DNS & SSL</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">DNS Configured</span>
|
||||||
|
<span [ngClass]="status()!.dns.configured ? 'badge-success' : 'badge-warning'" class="badge">
|
||||||
|
{{ status()!.dns.configured ? 'Yes' : 'No' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">SSL Configured</span>
|
||||||
|
<span [ngClass]="status()!.ssl.configured ? 'badge-success' : 'badge-warning'" class="badge">
|
||||||
|
{{ status()!.ssl.configured ? 'Yes' : 'No' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<a routerLink="/services/new" class="btn btn-primary">
|
||||||
|
Deploy New Service
|
||||||
|
</a>
|
||||||
|
<a routerLink="/services" class="btn btn-secondary">
|
||||||
|
View All Services
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class DashboardComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
|
||||||
|
status = signal<SystemStatus | null>(null);
|
||||||
|
loading = signal(true);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStatus(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getStatus().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.status.set(response.data);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
73
ui/src/app/features/dns/dns.component.ts
Normal file
73
ui/src/app/features/dns/dns.component.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dns',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-8">DNS Records</h1>
|
||||||
|
|
||||||
|
@if (records().length > 0) {
|
||||||
|
<div class="card overflow-hidden p-0">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
@for (record of records(); track record.domain) {
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.domain }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.type }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ record.value }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||||
|
<button (click)="deleteRecord(record)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="card text-center py-12">
|
||||||
|
<p class="text-gray-500">No DNS records configured</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-2">DNS records are created automatically when deploying services with domains</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class DnsComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
records = signal<any[]>([]);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRecords(): void {
|
||||||
|
this.apiService.getDnsRecords().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.records.set(response.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRecord(record: any): void {
|
||||||
|
if (confirm(`Delete DNS record for ${record.domain}?`)) {
|
||||||
|
this.apiService.deleteDnsRecord(record.domain).subscribe({
|
||||||
|
next: () => this.loadRecords(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
ui/src/app/features/login/login.component.ts
Normal file
103
ui/src/app/features/login/login.component.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Onebox
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Sign in to your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form class="mt-8 space-y-6" (ngSubmit)="onSubmit()">
|
||||||
|
<div class="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="sr-only">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="username"
|
||||||
|
required
|
||||||
|
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="sr-only">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
required
|
||||||
|
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="rounded-md bg-red-50 p-4">
|
||||||
|
<p class="text-sm text-red-800">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="loading"
|
||||||
|
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Signing in...' : 'Sign in' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-600 text-center">
|
||||||
|
<p>Default credentials: admin / admin</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Please change after first login</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LoginComponent {
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
username = '';
|
||||||
|
password = '';
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
this.error = '';
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
this.authService.login({ username: this.username, password: this.password }).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.loading = false;
|
||||||
|
if (response.success) {
|
||||||
|
this.router.navigate(['/dashboard']);
|
||||||
|
} else {
|
||||||
|
this.error = response.error || 'Login failed';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = err.error?.error || 'An error occurred during login';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
103
ui/src/app/features/registries/registries.component.ts
Normal file
103
ui/src/app/features/registries/registries.component.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ApiService, Registry } from '../../core/services/api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-registries',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-8">Docker Registries</h1>
|
||||||
|
|
||||||
|
<!-- Add Registry Form -->
|
||||||
|
<div class="card mb-8 max-w-2xl">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-4">Add Registry</h2>
|
||||||
|
<form (ngSubmit)="addRegistry()" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="url" class="label">Registry URL</label>
|
||||||
|
<input type="text" id="url" [(ngModel)]="newRegistry.url" name="url" required placeholder="registry.example.com" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="username" class="label">Username</label>
|
||||||
|
<input type="text" id="username" [(ngModel)]="newRegistry.username" name="username" required class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="label">Password</label>
|
||||||
|
<input type="password" id="password" [(ngModel)]="newRegistry.password" name="password" required class="input" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Add Registry</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registries List -->
|
||||||
|
@if (registries().length > 0) {
|
||||||
|
<div class="card overflow-hidden p-0">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
@for (registry of registries(); track registry.id) {
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.url }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.username }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(registry.createdAt) }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||||
|
<button (click)="deleteRegistry(registry)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class RegistriesComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
registries = signal<Registry[]>([]);
|
||||||
|
newRegistry = { url: '', username: '', password: '' };
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadRegistries();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRegistries(): void {
|
||||||
|
this.apiService.getRegistries().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.registries.set(response.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addRegistry(): void {
|
||||||
|
this.apiService.createRegistry(this.newRegistry).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.newRegistry = { url: '', username: '', password: '' };
|
||||||
|
this.loadRegistries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRegistry(registry: Registry): void {
|
||||||
|
if (confirm(`Delete registry ${registry.url}?`)) {
|
||||||
|
this.apiService.deleteRegistry(registry.url).subscribe({
|
||||||
|
next: () => this.loadRegistries(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(timestamp: number): string {
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
221
ui/src/app/features/services/service-create.component.ts
Normal file
221
ui/src/app/features/services/service-create.component.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
|
||||||
|
interface EnvVar {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-service-create',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-8">Deploy New Service</h1>
|
||||||
|
|
||||||
|
<div class="card max-w-3xl">
|
||||||
|
<form (ngSubmit)="onSubmit()">
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="name" class="label">Service Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
[(ngModel)]="name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
placeholder="myapp"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="image" class="label">Docker Image *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="image"
|
||||||
|
[(ngModel)]="image"
|
||||||
|
name="image"
|
||||||
|
required
|
||||||
|
placeholder="nginx:latest"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="port" class="label">Container Port *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="port"
|
||||||
|
[(ngModel)]="port"
|
||||||
|
name="port"
|
||||||
|
required
|
||||||
|
placeholder="80"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Port that your application listens on</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Domain -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="domain" class="label">Domain (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="domain"
|
||||||
|
[(ngModel)]="domain"
|
||||||
|
name="domain"
|
||||||
|
placeholder="app.example.com"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Leave empty to skip automatic DNS & SSL</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Environment Variables -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="label">Environment Variables</label>
|
||||||
|
@for (env of envVars(); track $index) {
|
||||||
|
<div class="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="env.key"
|
||||||
|
[name]="'envKey' + $index"
|
||||||
|
placeholder="KEY"
|
||||||
|
class="input flex-1"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="env.value"
|
||||||
|
[name]="'envValue' + $index"
|
||||||
|
placeholder="value"
|
||||||
|
class="input flex-1"
|
||||||
|
/>
|
||||||
|
<button type="button" (click)="removeEnvVar($index)" class="btn btn-danger">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button type="button" (click)="addEnvVar()" class="btn btn-secondary mt-2">
|
||||||
|
Add Environment Variable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="autoDNS"
|
||||||
|
[(ngModel)]="autoDNS"
|
||||||
|
name="autoDNS"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label for="autoDNS" class="ml-2 block text-sm text-gray-900">
|
||||||
|
Configure DNS automatically
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="autoSSL"
|
||||||
|
[(ngModel)]="autoSSL"
|
||||||
|
name="autoSSL"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label for="autoSSL" class="ml-2 block text-sm text-gray-900">
|
||||||
|
Obtain SSL certificate automatically
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<div class="rounded-md bg-red-50 p-4 mb-6">
|
||||||
|
<p class="text-sm text-red-800">{{ error() }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<button type="button" (click)="cancel()" class="btn btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" [disabled]="loading()" class="btn btn-primary">
|
||||||
|
{{ loading() ? 'Deploying...' : 'Deploy Service' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ServiceCreateComponent {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
name = '';
|
||||||
|
image = '';
|
||||||
|
port = 80;
|
||||||
|
domain = '';
|
||||||
|
autoDNS = true;
|
||||||
|
autoSSL = true;
|
||||||
|
envVars = signal<EnvVar[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
error = signal('');
|
||||||
|
|
||||||
|
addEnvVar(): void {
|
||||||
|
this.envVars.update((vars) => [...vars, { key: '', value: '' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEnvVar(index: number): void {
|
||||||
|
this.envVars.update((vars) => vars.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
this.error.set('');
|
||||||
|
this.loading.set(true);
|
||||||
|
|
||||||
|
// Convert env vars to object
|
||||||
|
const envVarsObj: Record<string, string> = {};
|
||||||
|
for (const env of this.envVars()) {
|
||||||
|
if (env.key && env.value) {
|
||||||
|
envVarsObj[env.key] = env.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: this.name,
|
||||||
|
image: this.image,
|
||||||
|
port: this.port,
|
||||||
|
domain: this.domain || undefined,
|
||||||
|
envVars: envVarsObj,
|
||||||
|
autoDNS: this.autoDNS,
|
||||||
|
autoSSL: this.autoSSL,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.apiService.createService(data).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
if (response.success) {
|
||||||
|
this.router.navigate(['/services']);
|
||||||
|
} else {
|
||||||
|
this.error.set(response.error || 'Failed to deploy service');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(err.error?.error || 'An error occurred');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.router.navigate(['/services']);
|
||||||
|
}
|
||||||
|
}
|
||||||
209
ui/src/app/features/services/service-detail.component.ts
Normal file
209
ui/src/app/features/services/service-detail.component.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { ApiService, Service } from '../../core/services/api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-service-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
} @else if (service()) {
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">{{ service()!.name }}</h1>
|
||||||
|
<span [ngClass]="{
|
||||||
|
'badge-success': service()!.status === 'running',
|
||||||
|
'badge-danger': service()!.status === 'stopped' || service()!.status === 'failed',
|
||||||
|
'badge-warning': service()!.status === 'starting' || service()!.status === 'stopping'
|
||||||
|
}" class="badge text-lg">
|
||||||
|
{{ service()!.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details Card -->
|
||||||
|
<div class="card mb-6">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-4">Service Details</h2>
|
||||||
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Image</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ service()!.image }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Port</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ service()!.port }}</dd>
|
||||||
|
</div>
|
||||||
|
@if (service()!.domain) {
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Domain</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<a [href]="'https://' + service()!.domain" target="_blank" class="text-primary-600 hover:text-primary-900">
|
||||||
|
{{ service()!.domain }}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (service()!.containerID) {
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Container ID</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 font-mono">{{ service()!.containerID?.substring(0, 12) }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.createdAt) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Updated</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">{{ formatDate(service()!.updatedAt) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Environment Variables -->
|
||||||
|
@if (Object.keys(service()!.envVars).length > 0) {
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 mb-2">Environment Variables</h3>
|
||||||
|
<div class="bg-gray-50 rounded-md p-4">
|
||||||
|
@for (entry of Object.entries(service()!.envVars); track entry[0]) {
|
||||||
|
<div class="flex justify-between py-1">
|
||||||
|
<span class="text-sm font-mono text-gray-700">{{ entry[0] }}</span>
|
||||||
|
<span class="text-sm font-mono text-gray-900">{{ entry[1] }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="card mb-6">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-4">Actions</h2>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
@if (service()!.status === 'stopped') {
|
||||||
|
<button (click)="startService()" class="btn btn-success">Start</button>
|
||||||
|
}
|
||||||
|
@if (service()!.status === 'running') {
|
||||||
|
<button (click)="stopService()" class="btn btn-secondary">Stop</button>
|
||||||
|
<button (click)="restartService()" class="btn btn-primary">Restart</button>
|
||||||
|
}
|
||||||
|
<button (click)="deleteService()" class="btn btn-danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Logs</h2>
|
||||||
|
<button (click)="refreshLogs()" class="btn btn-secondary text-sm">Refresh</button>
|
||||||
|
</div>
|
||||||
|
@if (loadingLogs()) {
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="bg-gray-900 rounded-md p-4 overflow-x-auto">
|
||||||
|
<pre class="text-xs text-gray-100 font-mono">{{ logs() || 'No logs available' }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ServiceDetailComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
service = signal<Service | null>(null);
|
||||||
|
logs = signal('');
|
||||||
|
loading = signal(true);
|
||||||
|
loadingLogs = signal(false);
|
||||||
|
|
||||||
|
Object = Object;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const name = this.route.snapshot.paramMap.get('name')!;
|
||||||
|
this.loadService(name);
|
||||||
|
this.loadLogs(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadService(name: string): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getService(name).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.service.set(response.data);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.router.navigate(['/services']);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLogs(name: string): void {
|
||||||
|
this.loadingLogs.set(true);
|
||||||
|
this.apiService.getServiceLogs(name).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.logs.set(response.data);
|
||||||
|
}
|
||||||
|
this.loadingLogs.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loadingLogs.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshLogs(): void {
|
||||||
|
this.loadLogs(this.service()!.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
startService(): void {
|
||||||
|
this.apiService.startService(this.service()!.name).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadService(this.service()!.name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopService(): void {
|
||||||
|
this.apiService.stopService(this.service()!.name).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadService(this.service()!.name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
restartService(): void {
|
||||||
|
this.apiService.restartService(this.service()!.name).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadService(this.service()!.name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteService(): void {
|
||||||
|
if (confirm(`Are you sure you want to delete ${this.service()!.name}?`)) {
|
||||||
|
this.apiService.deleteService(this.service()!.name).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.router.navigate(['/services']);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(timestamp: number): string {
|
||||||
|
return new Date(timestamp).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
150
ui/src/app/features/services/services-list.component.ts
Normal file
150
ui/src/app/features/services/services-list.component.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { ApiService, Service } from '../../core/services/api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-services-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterLink],
|
||||||
|
template: `
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<div class="sm:flex sm:items-center sm:justify-between mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Services</h1>
|
||||||
|
<div class="mt-4 sm:mt-0">
|
||||||
|
<a routerLink="/services/new" class="btn btn-primary">
|
||||||
|
Deploy New Service
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
} @else if (services().length === 0) {
|
||||||
|
<div class="card text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No services</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by deploying a new service.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<a routerLink="/services/new" class="btn btn-primary">
|
||||||
|
Deploy Service
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="card overflow-hidden p-0">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Image</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Domain</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
@for (service of services(); track service.id) {
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<a [routerLink]="['/services', service.name]" class="text-sm font-medium text-primary-600 hover:text-primary-900">
|
||||||
|
{{ service.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ service.image }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{{ service.domain || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span [ngClass]="{
|
||||||
|
'badge-success': service.status === 'running',
|
||||||
|
'badge-danger': service.status === 'stopped' || service.status === 'failed',
|
||||||
|
'badge-warning': service.status === 'starting' || service.status === 'stopping'
|
||||||
|
}" class="badge">
|
||||||
|
{{ service.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||||
|
@if (service.status === 'stopped') {
|
||||||
|
<button (click)="startService(service)" class="text-green-600 hover:text-green-900">Start</button>
|
||||||
|
}
|
||||||
|
@if (service.status === 'running') {
|
||||||
|
<button (click)="stopService(service)" class="text-yellow-600 hover:text-yellow-900">Stop</button>
|
||||||
|
<button (click)="restartService(service)" class="text-blue-600 hover:text-blue-900">Restart</button>
|
||||||
|
}
|
||||||
|
<button (click)="deleteService(service)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ServicesListComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
|
||||||
|
services = signal<Service[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadServices(): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.apiService.getServices().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.services.set(response.data);
|
||||||
|
}
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startService(service: Service): void {
|
||||||
|
this.apiService.startService(service.name).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadServices();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopService(service: Service): void {
|
||||||
|
this.apiService.stopService(service.name).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadServices();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
restartService(service: Service): void {
|
||||||
|
this.apiService.restartService(service.name).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadServices();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteService(service: Service): void {
|
||||||
|
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
|
||||||
|
this.apiService.deleteService(service.name).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.loadServices();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
ui/src/app/features/settings/settings.component.ts
Normal file
96
ui/src/app/features/settings/settings.component.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-8">Settings</h1>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Cloudflare Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-4">Cloudflare DNS</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">API Key</label>
|
||||||
|
<input type="password" [(ngModel)]="settings.cloudflareAPIKey" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Email</label>
|
||||||
|
<input type="email" [(ngModel)]="settings.cloudflareEmail" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">Zone ID</label>
|
||||||
|
<input type="text" [(ngModel)]="settings.cloudflareZoneID" class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-4">Server</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">Server IP</label>
|
||||||
|
<input type="text" [(ngModel)]="settings.serverIP" class="input" placeholder="1.2.3.4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">HTTP Port</label>
|
||||||
|
<input type="number" [(ngModel)]="settings.httpPort" class="input" placeholder="3000" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSL Settings -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-4">SSL / ACME</h2>
|
||||||
|
<div>
|
||||||
|
<label class="label">ACME Email</label>
|
||||||
|
<input type="email" [(ngModel)]="settings.acmeEmail" class="input" placeholder="admin@example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button (click)="saveSettings()" class="btn btn-primary">
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class SettingsComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
settings: any = {};
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettings(): void {
|
||||||
|
this.apiService.getSettings().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.settings = response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings(): void {
|
||||||
|
// Save each setting individually
|
||||||
|
const promises = Object.entries(this.settings).map(([key, value]) =>
|
||||||
|
this.apiService.updateSetting(key, value as string).toPromise()
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.all(promises).then(() => {
|
||||||
|
alert('Settings saved successfully');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
86
ui/src/app/features/ssl/ssl.component.ts
Normal file
86
ui/src/app/features/ssl/ssl.component.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ssl',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="px-4 sm:px-0">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 mb-8">SSL Certificates</h1>
|
||||||
|
|
||||||
|
@if (certificates().length > 0) {
|
||||||
|
<div class="card overflow-hidden p-0">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Issuer</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expiry</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
@for (cert of certificates(); track cert.domain) {
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.domain }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cert.issuer }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<span [ngClass]="isExpiringSoon(cert.expiryDate) ? 'text-red-600' : 'text-gray-500'">
|
||||||
|
{{ formatDate(cert.expiryDate) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||||
|
<button (click)="renewCertificate(cert)" class="text-primary-600 hover:text-primary-900">Renew</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="card text-center py-12">
|
||||||
|
<p class="text-gray-500">No SSL certificates</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-2">Certificates are obtained automatically when deploying services with domains</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class SslComponent implements OnInit {
|
||||||
|
private apiService = inject(ApiService);
|
||||||
|
certificates = signal<any[]>([]);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadCertificates();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCertificates(): void {
|
||||||
|
this.apiService.getSslCertificates().subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.certificates.set(response.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renewCertificate(cert: any): void {
|
||||||
|
this.apiService.renewSslCertificate(cert.domain).subscribe({
|
||||||
|
next: () => {
|
||||||
|
alert('Certificate renewal initiated');
|
||||||
|
this.loadCertificates();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(timestamp: number): string {
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpiringSoon(timestamp: number): boolean {
|
||||||
|
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
return timestamp - Date.now() < thirtyDays;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
ui/src/app/shared/components/layout.component.ts
Normal file
89
ui/src/app/shared/components/layout.component.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-layout',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||||
|
template: `
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="bg-white shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0 flex items-center">
|
||||||
|
<span class="text-2xl font-bold text-primary-600">Onebox</span>
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||||
|
<a
|
||||||
|
routerLink="/dashboard"
|
||||||
|
routerLinkActive="border-primary-500 text-gray-900"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/services"
|
||||||
|
routerLinkActive="border-primary-500 text-gray-900"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Services
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/registries"
|
||||||
|
routerLinkActive="border-primary-500 text-gray-900"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Registries
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/dns"
|
||||||
|
routerLinkActive="border-primary-500 text-gray-900"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
DNS
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/ssl"
|
||||||
|
routerLinkActive="border-primary-500 text-gray-900"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
SSL
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/settings"
|
||||||
|
routerLinkActive="border-primary-500 text-gray-900"
|
||||||
|
class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-sm text-gray-700 mr-4">{{ authService.currentUser()?.username }}</span>
|
||||||
|
<button (click)="logout()" class="btn btn-secondary text-sm">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LayoutComponent {
|
||||||
|
authService = inject(AuthService);
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.authService.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ui/src/favicon.ico
Normal file
1
ui/src/favicon.ico
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!-- Empty favicon placeholder -->
|
||||||
13
ui/src/index.html
Normal file
13
ui/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Onebox - Container Platform</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50">
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
ui/src/main.ts
Normal file
13
ui/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
import { routes } from './app/app.routes';
|
||||||
|
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, {
|
||||||
|
providers: [
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
],
|
||||||
|
}).catch((err) => console.error(err));
|
||||||
57
ui/src/styles.css
Normal file
57
ui/src/styles.css
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-600 text-white hover:bg-primary-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply bg-red-600 text-white hover:bg-red-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
@apply bg-green-600 text-white hover:bg-green-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-lg shadow-md p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
@apply px-2 py-1 text-xs font-semibold rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
@apply bg-green-100 text-green-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
@apply bg-yellow-100 text-yellow-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
@apply bg-red-100 text-red-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
@apply bg-blue-100 text-blue-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
ui/tailwind.config.js
Normal file
25
ui/tailwind.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{html,ts}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
13
ui/tsconfig.app.json
Normal file
13
ui/tsconfig.app.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
31
ui/tsconfig.json
Normal file
31
ui/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"dom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user