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",
|
||||
"version": "1.0.0",
|
||||
"exports": "./mod.ts",
|
||||
"nodeModulesDir": "auto",
|
||||
"tasks": {
|
||||
"test": "deno test --allow-all test/",
|
||||
"test:watch": "deno test --allow-all --watch test/",
|
||||
"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": {
|
||||
"@std/path": "jsr:@std/path@^1.0.0",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.0",
|
||||
"@std/http": "jsr:@std/http@^1.0.0",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.0",
|
||||
"@std/encoding": "jsr:@std/encoding@^1.0.0",
|
||||
"@db/sqlite": "jsr:@db/sqlite@^0.11.0",
|
||||
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.0.0",
|
||||
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^2.0.0",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^2.0.0",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^2.0.0"
|
||||
"@std/path": "jsr:@std/path@^1.1.2",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.19",
|
||||
"@std/http": "jsr:@std/http@^1.0.21",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.15",
|
||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
||||
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0",
|
||||
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@1.3.6",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"lib": ["deno.window", "deno.ns"],
|
||||
|
||||
@@ -48,5 +48,7 @@
|
||||
"cpu": [
|
||||
"x64",
|
||||
"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
|
||||
*/
|
||||
|
||||
import * as plugins from './onebox.plugins.ts';
|
||||
import { logger } from './onebox.logging.ts';
|
||||
import { projectInfo } from './onebox.info.ts';
|
||||
import type { Onebox } from './onebox.classes.onebox.ts';
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { projectInfo } from '../info.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 {
|
||||
private oneboxRef: Onebox;
|
||||
private smartdaemon: plugins.smartdaemon.SmartDaemon;
|
||||
private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null;
|
||||
private running = false;
|
||||
private monitoringInterval: number | null = null;
|
||||
private metricsInterval = 60000; // 1 minute
|
||||
private pidFilePath: string = PID_FILE_PATH;
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
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');
|
||||
if (customInterval) {
|
||||
this.metricsInterval = parseInt(customInterval, 10);
|
||||
}
|
||||
} catch {
|
||||
// Database not initialized yet - use defaults
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +48,11 @@ export class OneboxDaemon {
|
||||
try {
|
||||
logger.info('Installing Onebox daemon service...');
|
||||
|
||||
// Initialize smartdaemon if needed
|
||||
if (!this.smartdaemon) {
|
||||
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
|
||||
}
|
||||
|
||||
// Get installation directory
|
||||
const execPath = Deno.execPath();
|
||||
|
||||
@@ -63,6 +82,11 @@ export class OneboxDaemon {
|
||||
try {
|
||||
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');
|
||||
|
||||
if (service) {
|
||||
@@ -92,6 +116,12 @@ export class OneboxDaemon {
|
||||
|
||||
this.running = true;
|
||||
|
||||
// Load settings from database
|
||||
this.loadSettings();
|
||||
|
||||
// Write PID file
|
||||
await this.writePidFile();
|
||||
|
||||
// Start monitoring loop
|
||||
this.startMonitoring();
|
||||
|
||||
@@ -130,6 +160,9 @@ export class OneboxDaemon {
|
||||
// Stop HTTP server
|
||||
await this.oneboxRef.httpServer.stop();
|
||||
|
||||
// Remove PID file
|
||||
await this.removePidFile();
|
||||
|
||||
logger.success('Onebox daemon stopped');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to stop daemon: ${error.message}`);
|
||||
@@ -140,7 +173,7 @@ export class OneboxDaemon {
|
||||
/**
|
||||
* Start monitoring loop
|
||||
*/
|
||||
private startMonitoring(): void {
|
||||
public startMonitoring(): void {
|
||||
logger.info('Starting monitoring loop...');
|
||||
|
||||
this.monitoringInterval = setInterval(async () => {
|
||||
@@ -154,7 +187,7 @@ export class OneboxDaemon {
|
||||
/**
|
||||
* Stop monitoring loop
|
||||
*/
|
||||
private stopMonitoring(): void {
|
||||
public stopMonitoring(): void {
|
||||
if (this.monitoringInterval !== null) {
|
||||
clearInterval(this.monitoringInterval);
|
||||
this.monitoringInterval = null;
|
||||
@@ -262,11 +295,111 @@ export class OneboxDaemon {
|
||||
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
|
||||
*/
|
||||
async getServiceStatus(): Promise<string> {
|
||||
try {
|
||||
// Don't need smartdaemon to check status, just use systemctl directly
|
||||
const command = new Deno.Command('systemctl', {
|
||||
args: ['status', 'smartdaemon_onebox'],
|
||||
stdout: 'piped',
|
||||
@@ -2,7 +2,7 @@
|
||||
* Database layer for Onebox using SQLite
|
||||
*/
|
||||
|
||||
import * as plugins from './onebox.plugins.ts';
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type {
|
||||
IService,
|
||||
IRegistry,
|
||||
@@ -13,14 +13,14 @@ import type {
|
||||
ILogEntry,
|
||||
IUser,
|
||||
ISetting,
|
||||
} from './onebox.types.ts';
|
||||
import { logger } from './onebox.logging.ts';
|
||||
} from '../types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
|
||||
export class OneboxDatabase {
|
||||
private db: plugins.sqlite.DB | null = null;
|
||||
private dbPath: string;
|
||||
|
||||
constructor(dbPath = '/var/lib/onebox/onebox.db') {
|
||||
constructor(dbPath = './.nogit/onebox.db') {
|
||||
this.dbPath = dbPath;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export class OneboxDatabase {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
// Services table
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@@ -72,7 +72,7 @@ export class OneboxDatabase {
|
||||
`);
|
||||
|
||||
// Registries table
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS registries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
@@ -83,7 +83,7 @@ export class OneboxDatabase {
|
||||
`);
|
||||
|
||||
// Nginx configs table
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS nginx_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service_id INTEGER NOT NULL,
|
||||
@@ -98,7 +98,7 @@ export class OneboxDatabase {
|
||||
`);
|
||||
|
||||
// SSL certificates table
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS ssl_certificates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
@@ -113,7 +113,7 @@ export class OneboxDatabase {
|
||||
`);
|
||||
|
||||
// DNS records table
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS dns_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
@@ -126,7 +126,7 @@ export class OneboxDatabase {
|
||||
`);
|
||||
|
||||
// Metrics table
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service_id INTEGER NOT NULL,
|
||||
@@ -141,13 +141,13 @@ export class OneboxDatabase {
|
||||
`);
|
||||
|
||||
// Create index for metrics queries
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp
|
||||
ON metrics(service_id, timestamp DESC)
|
||||
`);
|
||||
|
||||
// Logs table
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service_id INTEGER NOT NULL,
|
||||
@@ -160,13 +160,13 @@ export class OneboxDatabase {
|
||||
`);
|
||||
|
||||
// Create index for logs queries
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp
|
||||
ON logs(service_id, timestamp DESC)
|
||||
`);
|
||||
|
||||
// Users table
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -178,7 +178,7 @@ export class OneboxDatabase {
|
||||
`);
|
||||
|
||||
// Settings table
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
@@ -187,7 +187,7 @@ export class OneboxDatabase {
|
||||
`);
|
||||
|
||||
// Version table for migrations
|
||||
this.db.query(`
|
||||
this.query(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
@@ -220,7 +220,7 @@ export class OneboxDatabase {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
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;
|
||||
} catch {
|
||||
return 0;
|
||||
@@ -233,7 +233,7 @@ export class OneboxDatabase {
|
||||
private setMigrationVersion(version: number): void {
|
||||
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,
|
||||
Date.now(),
|
||||
]);
|
||||
@@ -255,8 +255,27 @@ export class OneboxDatabase {
|
||||
* Execute a raw query
|
||||
*/
|
||||
query<T = unknown[]>(sql: string, params: unknown[] = []): T[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
return this.db.query(sql, params) as T[];
|
||||
if (!this.db) {
|
||||
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 ============
|
||||
@@ -265,7 +284,7 @@ export class OneboxDatabase {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
@@ -288,21 +307,21 @@ export class OneboxDatabase {
|
||||
getServiceByName(name: string): IService | null {
|
||||
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;
|
||||
}
|
||||
|
||||
getServiceByID(id: number): IService | null {
|
||||
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;
|
||||
}
|
||||
|
||||
getAllServices(): IService[] {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -345,12 +364,12 @@ export class OneboxDatabase {
|
||||
values.push(Date.now());
|
||||
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 {
|
||||
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 {
|
||||
@@ -375,7 +394,7 @@ export class OneboxDatabase {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const now = Date.now();
|
||||
this.db.query(
|
||||
this.query(
|
||||
'INSERT INTO registries (url, username, password_encrypted, created_at) VALUES (?, ?, ?, ?)',
|
||||
[registry.url, registry.username, registry.passwordEncrypted, now]
|
||||
);
|
||||
@@ -386,20 +405,20 @@ export class OneboxDatabase {
|
||||
getRegistryByURL(url: string): IRegistry | null {
|
||||
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;
|
||||
}
|
||||
|
||||
getAllRegistries(): IRegistry[] {
|
||||
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));
|
||||
}
|
||||
|
||||
deleteRegistry(url: string): void {
|
||||
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 {
|
||||
@@ -417,7 +436,7 @@ export class OneboxDatabase {
|
||||
getSetting(key: string): string | null {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -425,7 +444,7 @@ export class OneboxDatabase {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const now = Date.now();
|
||||
this.db.query(
|
||||
this.query(
|
||||
'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
|
||||
[key, value, now]
|
||||
);
|
||||
@@ -434,10 +453,13 @@ export class OneboxDatabase {
|
||||
getAllSettings(): Record<string, string> {
|
||||
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> = {};
|
||||
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;
|
||||
}
|
||||
@@ -448,7 +470,7 @@ export class OneboxDatabase {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const now = Date.now();
|
||||
this.db.query(
|
||||
this.query(
|
||||
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[user.username, user.passwordHash, user.role, now, now]
|
||||
);
|
||||
@@ -459,20 +481,20 @@ export class OneboxDatabase {
|
||||
getUserByUsername(username: string): IUser | null {
|
||||
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;
|
||||
}
|
||||
|
||||
getAllUsers(): IUser[] {
|
||||
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));
|
||||
}
|
||||
|
||||
updateUserPassword(username: string, passwordHash: string): void {
|
||||
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,
|
||||
Date.now(),
|
||||
username,
|
||||
@@ -481,17 +503,17 @@ export class OneboxDatabase {
|
||||
|
||||
deleteUser(username: string): void {
|
||||
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 {
|
||||
id: Number(row[0]),
|
||||
username: String(row[1]),
|
||||
passwordHash: String(row[2]),
|
||||
role: String(row[3]) as IUser['role'],
|
||||
createdAt: Number(row[4]),
|
||||
updatedAt: Number(row[5]),
|
||||
id: Number(row.id || row[0]),
|
||||
username: String(row.username || row[1]),
|
||||
passwordHash: String(row.password_hash || row[2]),
|
||||
role: String(row.role || row[3]) as IUser['role'],
|
||||
createdAt: Number(row.created_at || row[4]),
|
||||
updatedAt: Number(row.updated_at || row[5]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -500,7 +522,7 @@ export class OneboxDatabase {
|
||||
addMetric(metric: Omit<IMetric, 'id'>): void {
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
@@ -518,7 +540,7 @@ export class OneboxDatabase {
|
||||
getMetrics(serviceId: number, limit = 100): IMetric[] {
|
||||
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 ?',
|
||||
[serviceId, limit]
|
||||
);
|
||||
@@ -543,7 +565,7 @@ export class OneboxDatabase {
|
||||
addLog(log: Omit<ILogEntry, 'id'>): void {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
this.db.query(
|
||||
this.query(
|
||||
'INSERT INTO logs (service_id, timestamp, message, level, source) VALUES (?, ?, ?, ?, ?)',
|
||||
[log.serviceId, log.timestamp, log.message, log.level, log.source]
|
||||
);
|
||||
@@ -552,7 +574,7 @@ export class OneboxDatabase {
|
||||
getLogs(serviceId: number, limit = 1000): ILogEntry[] {
|
||||
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 ?',
|
||||
[serviceId, limit]
|
||||
);
|
||||
@@ -576,7 +598,7 @@ export class OneboxDatabase {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
@@ -597,14 +619,14 @@ export class OneboxDatabase {
|
||||
getSSLCertificate(domain: string): ISslCertificate | null {
|
||||
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;
|
||||
}
|
||||
|
||||
getAllSSLCertificates(): ISslCertificate[] {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -635,12 +657,12 @@ export class OneboxDatabase {
|
||||
values.push(Date.now());
|
||||
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 {
|
||||
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 {
|
||||
@@ -4,14 +4,14 @@
|
||||
* Manages DNS records via Cloudflare API
|
||||
*/
|
||||
|
||||
import * as plugins from './onebox.plugins.ts';
|
||||
import { logger } from './onebox.logging.ts';
|
||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
|
||||
export class OneboxDnsManager {
|
||||
private oneboxRef: any;
|
||||
private database: OneboxDatabase;
|
||||
private cloudflareClient: plugins.cloudflare.Cloudflare | null = null;
|
||||
private cloudflareClient: plugins.cloudflare.CloudflareAccount | null = null;
|
||||
private zoneID: string | null = null;
|
||||
private serverIP: string | null = null;
|
||||
|
||||
@@ -28,27 +28,58 @@ export class OneboxDnsManager {
|
||||
// Get Cloudflare credentials from settings
|
||||
const apiKey = this.database.getSetting('cloudflareAPIKey');
|
||||
const email = this.database.getSetting('cloudflareEmail');
|
||||
const zoneID = this.database.getSetting('cloudflareZoneID');
|
||||
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.info('Configure with: onebox config set cloudflareAPIKey <key>');
|
||||
logger.info('Configure with: onebox config set cloudflareEmail <email>');
|
||||
return;
|
||||
}
|
||||
|
||||
this.zoneID = zoneID;
|
||||
this.serverIP = serverIP;
|
||||
|
||||
// Initialize Cloudflare client
|
||||
this.cloudflareClient = new plugins.cloudflare.Cloudflare({
|
||||
apiKey,
|
||||
email,
|
||||
});
|
||||
// The CloudflareAccount class expects just the API key/token
|
||||
this.cloudflareClient = new plugins.cloudflare.CloudflareAccount(apiKey);
|
||||
|
||||
// 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');
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
* Handles all Docker operations: containers, images, networks, volumes
|
||||
*/
|
||||
|
||||
import * as plugins from './onebox.plugins.ts';
|
||||
import type { IService, IContainerStats } from './onebox.types.ts';
|
||||
import { logger } from './onebox.logging.ts';
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IService, IContainerStats } from '../types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
|
||||
export class OneboxDockerManager {
|
||||
private dockerClient: plugins.docker.Docker | null = null;
|
||||
@@ -22,6 +22,9 @@ export class OneboxDockerManager {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
});
|
||||
|
||||
// Start the Docker client
|
||||
await this.dockerClient.start();
|
||||
|
||||
logger.info('Docker client initialized');
|
||||
|
||||
// Ensure onebox network exists
|
||||
@@ -37,8 +40,8 @@ export class OneboxDockerManager {
|
||||
*/
|
||||
private async ensureNetwork(): Promise<void> {
|
||||
try {
|
||||
const networks = await this.dockerClient!.listNetworks();
|
||||
const existingNetwork = networks.find((n: any) => n.Name === this.networkName);
|
||||
const networks = await this.dockerClient!.getNetworks();
|
||||
const existingNetwork = networks.find((n: any) => n.name === this.networkName);
|
||||
|
||||
if (!existingNetwork) {
|
||||
logger.info(`Creating Docker network: ${this.networkName}`);
|
||||
@@ -370,14 +373,11 @@ export class OneboxDockerManager {
|
||||
*/
|
||||
async listContainers(): Promise<any[]> {
|
||||
try {
|
||||
const containers = await this.dockerClient!.listContainers({
|
||||
all: true,
|
||||
filters: {
|
||||
label: ['managed-by=onebox'],
|
||||
},
|
||||
});
|
||||
|
||||
return containers;
|
||||
const containers = await this.dockerClient!.getContainers();
|
||||
// Filter for onebox-managed containers
|
||||
return containers.filter((c: any) =>
|
||||
c.labels && c.labels['managed-by'] === 'onebox'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list containers: ${error.message}`);
|
||||
return [];
|
||||
@@ -4,10 +4,10 @@
|
||||
* Serves REST API and Angular UI
|
||||
*/
|
||||
|
||||
import * as plugins from './onebox.plugins.ts';
|
||||
import { logger } from './onebox.logging.ts';
|
||||
import type { Onebox } from './onebox.classes.onebox.ts';
|
||||
import type { IApiResponse } from './onebox.types.ts';
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
import type { IApiResponse } from '../types.ts';
|
||||
|
||||
export class OneboxHttpServer {
|
||||
private oneboxRef: Onebox;
|
||||
@@ -75,16 +75,93 @@ export class OneboxHttpServer {
|
||||
return await this.handleApiRequest(req, path);
|
||||
}
|
||||
|
||||
// Serve Angular UI (TODO: implement static file serving)
|
||||
return new Response('Onebox API - UI coming soon', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
// Serve Angular UI
|
||||
return await this.serveStaticFile(path);
|
||||
} catch (error) {
|
||||
logger.error(`Request error: ${error.message}`);
|
||||
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
|
||||
*/
|
||||
@@ -101,8 +178,14 @@ export class OneboxHttpServer {
|
||||
}
|
||||
|
||||
// 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();
|
||||
} 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') {
|
||||
return await this.handleListServicesRequest();
|
||||
} else if (path === '/api/services' && method === 'POST') {
|
||||
@@ -132,6 +215,58 @@ export class OneboxHttpServer {
|
||||
|
||||
// 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> {
|
||||
const status = await this.oneboxRef.getSystemStatus();
|
||||
return this.jsonResponse({ success: true, data: status });
|
||||
@@ -181,6 +316,40 @@ export class OneboxHttpServer {
|
||||
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
|
||||
*/
|
||||
@@ -4,23 +4,23 @@
|
||||
* Coordinates all components and provides the main API
|
||||
*/
|
||||
|
||||
import { logger } from './onebox.logging.ts';
|
||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
||||
import { OneboxDockerManager } from './onebox.classes.docker.ts';
|
||||
import { OneboxServicesManager } from './onebox.classes.services.ts';
|
||||
import { OneboxRegistriesManager } from './onebox.classes.registries.ts';
|
||||
import { OneboxNginxManager } from './onebox.classes.nginx.ts';
|
||||
import { OneboxDnsManager } from './onebox.classes.dns.ts';
|
||||
import { OneboxSslManager } from './onebox.classes.ssl.ts';
|
||||
import { OneboxDaemon } from './onebox.classes.daemon.ts';
|
||||
import { OneboxHttpServer } from './onebox.classes.httpserver.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { OneboxDockerManager } from './docker.ts';
|
||||
import { OneboxServicesManager } from './services.ts';
|
||||
import { OneboxRegistriesManager } from './registries.ts';
|
||||
import { OneboxReverseProxy } from './reverseproxy.ts';
|
||||
import { OneboxDnsManager } from './dns.ts';
|
||||
import { OneboxSslManager } from './ssl.ts';
|
||||
import { OneboxDaemon } from './daemon.ts';
|
||||
import { OneboxHttpServer } from './httpserver.ts';
|
||||
|
||||
export class Onebox {
|
||||
public database: OneboxDatabase;
|
||||
public docker: OneboxDockerManager;
|
||||
public services: OneboxServicesManager;
|
||||
public registries: OneboxRegistriesManager;
|
||||
public nginx: OneboxNginxManager;
|
||||
public reverseProxy: OneboxReverseProxy;
|
||||
public dns: OneboxDnsManager;
|
||||
public ssl: OneboxSslManager;
|
||||
public daemon: OneboxDaemon;
|
||||
@@ -36,7 +36,7 @@ export class Onebox {
|
||||
this.docker = new OneboxDockerManager();
|
||||
this.services = new OneboxServicesManager(this);
|
||||
this.registries = new OneboxRegistriesManager(this);
|
||||
this.nginx = new OneboxNginxManager(this);
|
||||
this.reverseProxy = new OneboxReverseProxy(this);
|
||||
this.dns = new OneboxDnsManager(this);
|
||||
this.ssl = new OneboxSslManager(this);
|
||||
this.daemon = new OneboxDaemon(this);
|
||||
@@ -59,8 +59,8 @@ export class Onebox {
|
||||
// Initialize Docker
|
||||
await this.docker.init();
|
||||
|
||||
// Initialize Nginx
|
||||
await this.nginx.init();
|
||||
// Initialize Reverse Proxy
|
||||
await this.reverseProxy.init();
|
||||
|
||||
// Initialize DNS (non-critical)
|
||||
try {
|
||||
@@ -97,15 +97,12 @@ export class Onebox {
|
||||
if (!adminUser) {
|
||||
logger.info('Creating default admin user...');
|
||||
|
||||
// Hash default password 'admin'
|
||||
const passwordHash = await Deno.readTextFile('/dev/urandom').then((data) =>
|
||||
// Simple hash for now - should use bcrypt
|
||||
btoa('admin')
|
||||
);
|
||||
// Simple base64 encoding for now - should use bcrypt in production
|
||||
const passwordHash = btoa('admin');
|
||||
|
||||
await this.database.createUser({
|
||||
username: 'admin',
|
||||
passwordHash: btoa('admin'), // Simple encoding for now
|
||||
passwordHash,
|
||||
role: 'admin',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
@@ -132,7 +129,7 @@ export class Onebox {
|
||||
async getSystemStatus() {
|
||||
try {
|
||||
const dockerRunning = await this.docker.isDockerRunning();
|
||||
const nginxStatus = await this.nginx.getStatus();
|
||||
const proxyStatus = this.reverseProxy.getStatus();
|
||||
const dnsConfigured = this.dns.isConfigured();
|
||||
const sslConfigured = this.ssl.isConfigured();
|
||||
|
||||
@@ -145,10 +142,7 @@ export class Onebox {
|
||||
running: dockerRunning,
|
||||
version: dockerRunning ? await this.docker.getDockerVersion() : null,
|
||||
},
|
||||
nginx: {
|
||||
status: nginxStatus,
|
||||
installed: await this.nginx.isInstalled(),
|
||||
},
|
||||
reverseProxy: proxyStatus,
|
||||
dns: {
|
||||
configured: dnsConfigured,
|
||||
},
|
||||
@@ -209,6 +203,9 @@ export class Onebox {
|
||||
// Stop HTTP server if running
|
||||
await this.httpServer.stop();
|
||||
|
||||
// Stop reverse proxy if running
|
||||
await this.reverseProxy.stop();
|
||||
|
||||
// Close database
|
||||
this.database.close();
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* Manages Docker registry credentials and authentication
|
||||
*/
|
||||
|
||||
import * as plugins from './onebox.plugins.ts';
|
||||
import type { IRegistry } from './onebox.types.ts';
|
||||
import { logger } from './onebox.logging.ts';
|
||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { IRegistry } from '../types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
|
||||
export class OneboxRegistriesManager {
|
||||
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
|
||||
*/
|
||||
|
||||
import type { IService, IServiceDeployOptions } from './onebox.types.ts';
|
||||
import { logger } from './onebox.logging.ts';
|
||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
||||
import { OneboxDockerManager } from './onebox.classes.docker.ts';
|
||||
import type { IService, IServiceDeployOptions } from '../types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { OneboxDockerManager } from './docker.ts';
|
||||
|
||||
export class OneboxServicesManager {
|
||||
private oneboxRef: any; // Will be Onebox instance
|
||||
@@ -77,19 +77,18 @@ export class OneboxServicesManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure nginx
|
||||
// Configure reverse proxy
|
||||
try {
|
||||
await this.oneboxRef.nginx.createConfig(service.id!, options.domain, options.port);
|
||||
await this.oneboxRef.nginx.reload();
|
||||
await this.oneboxRef.reverseProxy.addRoute(service.id!, options.domain, options.port);
|
||||
} 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)
|
||||
if (options.autoSSL !== false) {
|
||||
try {
|
||||
await this.oneboxRef.ssl.obtainCertificate(options.domain);
|
||||
await this.oneboxRef.nginx.reload();
|
||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||
} catch (error) {
|
||||
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) {
|
||||
try {
|
||||
await this.oneboxRef.nginx.removeConfig(service.id!);
|
||||
await this.oneboxRef.nginx.reload();
|
||||
this.oneboxRef.reverseProxy.removeRoute(service.domain);
|
||||
} 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
|
||||
@@ -4,9 +4,9 @@
|
||||
* Manages SSL certificates via Let's Encrypt (using smartacme)
|
||||
*/
|
||||
|
||||
import * as plugins from './onebox.plugins.ts';
|
||||
import { logger } from './onebox.logging.ts';
|
||||
import { OneboxDatabase } from './onebox.classes.database.ts';
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
|
||||
export class OneboxSslManager {
|
||||
private oneboxRef: any;
|
||||
@@ -106,8 +106,8 @@ export class OneboxSslManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Enable SSL in nginx config
|
||||
await this.oneboxRef.nginx.enableSSL(domain);
|
||||
// Reload certificates in reverse proxy
|
||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||
|
||||
logger.success(`SSL certificate obtained for ${domain}`);
|
||||
} catch (error) {
|
||||
@@ -182,8 +182,8 @@ export class OneboxSslManager {
|
||||
|
||||
logger.success(`Certificate renewed for ${domain}`);
|
||||
|
||||
// Reload nginx
|
||||
await this.oneboxRef.nginx.reload();
|
||||
// Reload certificates in reverse proxy
|
||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
|
||||
throw error;
|
||||
@@ -256,8 +256,8 @@ export class OneboxSslManager {
|
||||
|
||||
logger.success('All certificates renewed');
|
||||
|
||||
// Reload nginx
|
||||
await this.oneboxRef.nginx.reload();
|
||||
// Reload certificates in reverse proxy
|
||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to renew all certificates: ${error.message}`);
|
||||
throw error;
|
||||
@@ -2,9 +2,10 @@
|
||||
* CLI Router for Onebox
|
||||
*/
|
||||
|
||||
import { logger } from './onebox.logging.ts';
|
||||
import { projectInfo } from './onebox.info.ts';
|
||||
import { Onebox } from './onebox.classes.onebox.ts';
|
||||
import { logger } from './logging.ts';
|
||||
import { projectInfo } from './info.ts';
|
||||
import { Onebox } from './classes/onebox.ts';
|
||||
import { OneboxDaemon } from './classes/daemon.ts';
|
||||
|
||||
export async function runCli(): Promise<void> {
|
||||
const args = Deno.args;
|
||||
@@ -23,6 +24,15 @@ export async function runCli(): Promise<void> {
|
||||
const subcommand = args[1];
|
||||
|
||||
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
|
||||
const onebox = new Onebox();
|
||||
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
|
||||
async function handleDaemonCommand(onebox: Onebox, subcommand: string, _args: string[]) {
|
||||
switch (subcommand) {
|
||||
@@ -316,6 +382,12 @@ Onebox v${projectInfo.version} - Self-hosted container platform
|
||||
Usage: onebox <command> [options]
|
||||
|
||||
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 remove <name>
|
||||
service start <name>
|
||||
@@ -357,7 +429,17 @@ Options:
|
||||
--version, -v Show version
|
||||
--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:
|
||||
onebox server --ephemeral # Start dev server
|
||||
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 daemon install
|
||||
29
ts/index.ts
29
ts/index.ts
@@ -2,23 +2,24 @@
|
||||
* Main exports and CLI entry point for Onebox
|
||||
*/
|
||||
|
||||
export { Onebox } from './onebox.classes.onebox.ts';
|
||||
export { runCli } from './onebox.cli.ts';
|
||||
export { OneboxDatabase } from './onebox.classes.database.ts';
|
||||
export { OneboxDockerManager } from './onebox.classes.docker.ts';
|
||||
export { OneboxServicesManager } from './onebox.classes.services.ts';
|
||||
export { OneboxRegistriesManager } from './onebox.classes.registries.ts';
|
||||
export { OneboxNginxManager } from './onebox.classes.nginx.ts';
|
||||
export { OneboxDnsManager } from './onebox.classes.dns.ts';
|
||||
export { OneboxSslManager } from './onebox.classes.ssl.ts';
|
||||
export { OneboxDaemon } from './onebox.classes.daemon.ts';
|
||||
export { OneboxHttpServer } from './onebox.classes.httpserver.ts';
|
||||
export { Onebox } from './classes/onebox.ts';
|
||||
export { runCli } from './cli.ts';
|
||||
export { OneboxDatabase } from './classes/database.ts';
|
||||
export { OneboxDockerManager } from './classes/docker.ts';
|
||||
export { OneboxServicesManager } from './classes/services.ts';
|
||||
export { OneboxRegistriesManager } from './classes/registries.ts';
|
||||
export { OneboxReverseProxy } from './classes/reverseproxy.ts';
|
||||
export { OneboxDnsManager } from './classes/dns.ts';
|
||||
export { OneboxSslManager } from './classes/ssl.ts';
|
||||
export { OneboxDaemon } from './classes/daemon.ts';
|
||||
export { OneboxHttpServer } from './classes/httpserver.ts';
|
||||
export { OneboxApiClient } from './classes/apiclient.ts';
|
||||
|
||||
// Types
|
||||
export * from './onebox.types.ts';
|
||||
export * from './types.ts';
|
||||
|
||||
// Logging
|
||||
export { logger } from './onebox.logging.ts';
|
||||
export { logger } from './logging.ts';
|
||||
|
||||
// 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 };
|
||||
|
||||
// Database
|
||||
import * as sqlite from '@db/sqlite';
|
||||
export { sqlite };
|
||||
import { Database } from '@db/sqlite';
|
||||
export const sqlite = { DB: Database };
|
||||
|
||||
// Systemd Daemon Integration
|
||||
import * as smartdaemon from '@push.rocks/smartdaemon';
|
||||
export { smartdaemon };
|
||||
|
||||
// Docker API Client
|
||||
import * as docker from '@apiclient.xyz/docker';
|
||||
export { docker };
|
||||
import { DockerHost } from '@apiclient.xyz/docker';
|
||||
export const docker = { Docker: DockerHost };
|
||||
|
||||
// Cloudflare DNS Management
|
||||
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 };
|
||||
|
||||
// JWT for authentication
|
||||
import { create as createJwt, verify as verifyJwt, decode as decodeJwt } from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
|
||||
export { createJwt, verifyJwt, decodeJwt };
|
||||
import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts';
|
||||
export { jwt};
|
||||
|
||||
// Crypto key management
|
||||
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