initial commit

Signed-off-by: sisungo <[email protected]>
This commit is contained in:
2026-09-13 11:24:21 +00:00
commit 1b3d2b768a
58 changed files with 8414 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# Dependencies
node_modules/
# Build output
build/
.svelte-kit/
# Rust build artifacts
agent/target/
# Runtime data
build-server.db
build-server.db-shm
build-server.db-wal
uploads/
logs/
# Config with secrets
config.toml
agent/agent.toml
# OS
.DS_Store
+221
View File
@@ -0,0 +1,221 @@
# SemiOS Build Server
A web-based package build server for SemiOS. Uses SvelteKit SSR for the UI and a Rust agent for executing builds on remote machines.
## Requirements
### Build Server
- **Node.js** >= 18
- **npm**
- **Git**
- A running instance of the [packie](https://gitea.semilabs.org/semios/packie.git) package manager on the build machines
### Build Agent (per machine)
- **Rust** >= 1.85 (edition 2024)
- **Git**
- The [ports](https://gitea.semilabs.org/semios/ports.git) repository cloned on the machine
- `packie` installed and available in `$PATH`
## Quick Start
### 1. Clone and Build the Server
```bash
git clone https://gitea.semilabs.org/semios/semios-build-server.git
cd semios-build-server
npm install
npm run build
```
### 2. Configure the Server
Copy and edit `config.toml` at the project root:
```toml
[server]
host = "0.0.0.0"
port = 3000
[ports]
repo_url = "https://gitea.semilabs.org/semios/ports.git"
repo_path = "/path/to/ports-cache"
sync_interval_secs = 300
[repo]
publish_dir = "/path/to/repo-output"
[agent]
token = "your-shared-secret-token"
listen_port = 3001
[users]
admin = "scrypt$<salt>$<hash>"
```
| Key | Description |
|-----|-------------|
| `server.host` | Bind address (`0.0.0.0` for all interfaces) |
| `server.port` | Web UI port |
| `ports.repo_url` | Git URL of the ports repository |
| `ports.repo_path` | Local path where the server clones/caches the ports repo |
| `ports.sync_interval_secs` | How often to pull from upstream (seconds) |
| `repo.publish_dir` | Where published packages are collected |
| `agent.token` | Shared secret for agent authentication |
| `agent.listen_port` | Port the server expects agents to expose (for display only) |
| `users.*` | User accounts (username = scrypt password hash) |
### 3. Add Users
Only logged-in users can start builds, add packages to the repository, or delete artifacts.
Use the **registration helper** at `/register` to generate password hashes. For each user:
1. Open `http://localhost:3000/register` in a browser
2. Enter username and password, click **Generate Config Snippet**
3. Copy the `[users]` line into `config.toml`
4. Restart the server
Or generate hashes from the command line:
```bash
node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync('your-password', salt, 64).toString('hex');
console.log('scrypt\$' + salt + '\$' + hash);
"
```
### 3. Start the Server
```bash
BODY_SIZE_LIMIT=52428800 node build/index.js
```
Or use the npm script:
```bash
npm run start
```
The server listens on `http://0.0.0.0:3000` by default.
#### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `BODY_SIZE_LIMIT` | - | Max request body size in bytes (set to `52428800` for artifact uploads) |
| `PORT` | - | Override the listen port |
### 4. Build and Start the Agent
On each build machine:
```bash
cd agent
cargo build --release
cp agent.toml /etc/semios-build-agent/agent.toml # or any location
./target/release/semios-build-agent
```
Edit `agent.toml` before running:
```toml
master_url = "http://server-ip:3000"
name = "worker-1"
hostname = "192.168.1.100"
port = 3001
token = "your-shared-secret-token"
ports_repo_path = "/path/to/ports-on-this-machine"
```
| Key | Description |
|-----|-------------|
| `master_url` | URL of the build server |
| `name` | Unique machine name (used for routing builds) |
| `hostname` | Reachable hostname/IP for the server to connect back |
| `port` | Port the agent listens on |
| `token` | Must match the server's `agent.token` |
| `ports_repo_path` | Path to the ports repo on this machine |
### 5. Register a Machine
Machines register automatically when they connect. You can also add them manually via the web UI at `/machines` or by calling the API:
```bash
curl -X POST http://localhost:3000/api/machines \
-H 'Content-Type: application/json' \
-d '{"name":"worker-1","hostname":"192.168.1.100","port":3001}'
```
### 6. Load Packages
Click **Sync from Git** in the web UI (`/packages`) or call:
```bash
curl -X POST http://localhost:3000/api/sync
```
## Architecture
```
┌──────────────┐ ┌──────────────┐
│ Web UI │◄───────►│ Build Server │
│ (browser) │ HTTP │ Node.js │
└──────────────┘ └──────┬───────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Agent A │ │ Agent B │ │ Agent C │
│ (aarch64)│ │ (x86_64) │ │ (riscv64)│
└──────────┘ └──────────┘ └──────────┘
```
- **Build Server** — SvelteKit SSR app with SQLite database. Manages packages, builds, artifacts, and dispatches build jobs to agents.
- **Build Agent** — Rust binary. Polls the server for pending builds, executes `./x --atombuild --target <target> build <package>`, streams logs in real-time, and uploads `.pkg` artifacts.
## File Layout
```
~/.cache/semios-build/
ports/ # cloned ports repo (server-side)
repo/ # published packages output
project-root/
config.toml # server configuration
build-server.db # SQLite database (auto-created)
uploads/ # build artifacts (build-<id>/)
logs/ # build log files
agent/
agent.toml # agent configuration
```
## Build Workflow
1. A user clicks **Build** on a package, selects architecture and atombuild option in the dialog.
2. The server creates a build record with status `queued` and computes the target (e.g. `aarch64-semios-linux`).
3. The dispatcher assigns the build to an idle machine with matching architecture.
4. The agent clones/updates the ports repo, runs the build, and streams logs to the server via `POST /api/builds/<id>/log`.
5. On completion, the agent discovers `.pkg` files and uploads them as artifacts.
6. The server marks the build as `success` or `failed`.
7. Artifacts can be added to the repository via **Add to Repo**, which calls `packie repo-admin --add-package`.
## Development
```bash
npm run dev # starts vite dev server with HMR
```
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| 500 errors on pages | Kill stale processes, rebuild with `rm -rf build .svelte-kit && npm run build`, restart |
| Agent not receiving builds | Check `master_url`, `token`, and network connectivity |
| Build fails immediately | Ensure `packie` is in `$PATH` and ports repo is cloned at `ports_repo_path` |
| Artifact upload fails | Set `BODY_SIZE_LIMIT=52428800` (50 MB) on the server |
| 401 on build/repo actions | Login required — use the sidebar login link or visit `/login` |
| Cannot add users | Generate hash via `/register` helper or CLI snippet, add `[users]` section to `config.toml` |
+1825
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "semios-build-agent"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "registry", "fmt", "std"] }
toml = "0.8"
sysinfo = "0.36"
anyhow = "1"
base64 = "0.22"
+7
View File
@@ -0,0 +1,7 @@
[agent]
master_url = "http://your-primary-machine:3000"
name = "build-worker-1"
hostname = "192.168.1.100"
port = 3001
token = "change-me-to-a-secret-token"
ports_repo_path = "/var/lib/semios-build/ports"
+376
View File
@@ -0,0 +1,376 @@
use axum::{routing::post, Json, Router};
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use sysinfo::System;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tracing::{info, error};
#[derive(Debug, Deserialize, Clone)]
struct AgentConfig {
master_url: String,
name: String,
hostname: String,
port: u16,
token: String,
ports_repo_path: String,
#[serde(default = "default_clean_interval")]
clean_interval_secs: u64,
}
fn default_clean_interval() -> u64 { 21600 }
#[derive(Debug, Deserialize)]
struct BuildRequest {
build_id: i64,
package_name: String,
target: String,
atombuild: bool,
}
#[derive(Debug, Serialize)]
struct BuildResponse {
status: String,
message: String,
artifacts: Vec<String>,
}
fn get_system_stats() -> (f64, f64, f64, f64) {
let mut sys = System::new_all();
sys.refresh_all();
let cpu = sys.global_cpu_usage();
let mem_total = sys.total_memory() as f64 / 1024.0 / 1024.0;
let mem_used = sys.used_memory() as f64 / 1024.0 / 1024.0;
let mem_pct = if mem_total > 0.0 { mem_used / mem_total * 100.0 } else { 0.0 };
// Get disk usage for the partition containing the ports repo
let disk_pct = std::process::Command::new("df")
.args(["--output=pcent", "/"])
.output()
.ok()
.and_then(|o| {
let out = String::from_utf8_lossy(&o.stdout).to_string();
out.lines().nth(1)?.trim().trim_end_matches('%').parse::<f64>().ok()
})
.unwrap_or(0.0);
(cpu as f64, mem_pct as f64, mem_total, disk_pct)
}
fn get_host_arch() -> String {
std::process::Command::new("packie")
.args(["print", "profile.host_arch"])
.output()
.map(|output| {
if output.status.success() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
error!("Failed to run packie print profile.host_arch");
std::env::consts::ARCH.to_string()
}
})
.unwrap_or_else(|e| {
error!("Failed to execute packie: {}", e);
std::env::consts::ARCH.to_string()
})
}
fn run_clean(ports_path: &str) {
info!("Running ./x clean --all");
let output = std::process::Command::new("./x")
.args(["clean", "--all"])
.current_dir(ports_path)
.output();
match output {
Ok(o) => {
if o.status.success() {
info!("Clean succeeded");
} else {
error!("Clean failed: {}", String::from_utf8_lossy(&o.stderr));
}
}
Err(e) => error!("Failed to execute clean: {}", e),
}
}
async fn handle_build(
axum::extract::State(state): axum::extract::State<AgentState>,
Json(req): Json<BuildRequest>,
) -> Json<BuildResponse> {
info!("Build request received: {:?}", req);
state.building.store(true, Ordering::SeqCst);
let ports_path = std::path::PathBuf::from(&state.config.ports_repo_path);
let pkg_dir = ports_path.join("packages").join(&req.package_name);
if !pkg_dir.exists() {
state.building.store(false, Ordering::SeqCst);
return Json(BuildResponse {
status: "error".to_string(),
message: format!("Package directory not found: {}", pkg_dir.display()),
artifacts: Vec::new(),
});
}
let mut cmd = std::process::Command::new("./x");
if req.atombuild {
cmd.arg("--atombuild");
}
cmd.arg("--target").arg(&req.target);
cmd.arg("build").arg(&req.package_name);
cmd.current_dir(&ports_path);
cmd.env("PYTHONUNBUFFERED", "1");
info!("Executing: ./x {} build {}", if req.atombuild { "--atombuild" } else { "" }, req.package_name);
let log_url = format!("{}/api/builds/{}/log", state.config.master_url, req.build_id);
let client = reqwest::Client::new();
let mut child = match Command::new("./x")
.arg("--atombuild")
.arg("--target").arg(&req.target)
.arg("build").arg(&req.package_name)
.current_dir(&ports_path)
.env("PYTHONUNBUFFERED", "1")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
error!("Failed to spawn build: {}", e);
state.building.store(false, Ordering::SeqCst);
return Json(BuildResponse {
status: "error".to_string(),
message: e.to_string(),
artifacts: Vec::new(),
});
}
};
// Stream stdout and stderr concurrently via tokio tasks
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let stdout_url = log_url.clone();
let stderr_url = log_url.clone();
let client_out = client.clone();
let client_err = client.clone();
let t1 = tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = client_out.post(&stdout_url)
.json(&serde_json::json!({ "line": &line }))
.send()
.await;
}
});
let t2 = tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = client_err.post(&stderr_url)
.json(&serde_json::json!({ "line": &line }))
.send()
.await;
}
});
let status = child.wait().await;
let _ = tokio::join!(t1, t2);
let result = match status {
Ok(exit_status) => {
if exit_status.success() {
info!("Build {} succeeded", req.package_name);
let target_dir = pkg_dir.join("target");
let mut artifacts = Vec::new();
if target_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&target_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(".pkg") {
artifacts.push(name);
}
}
}
}
if !artifacts.is_empty() {
let mut files = Vec::new();
for name in &artifacts {
let file_path = target_dir.join(name);
if let Ok(data) = std::fs::read(&file_path) {
files.push(serde_json::json!({
"name": name,
"content": base64::engine::general_purpose::STANDARD.encode(&data),
}));
}
}
let upload_url = format!("{}/api/builds/{}/artifacts", state.config.master_url, req.build_id);
match client.post(&upload_url).json(&serde_json::json!({ "files": files })).send().await {
Ok(resp) => {
if resp.status().is_success() {
info!("Uploaded {} artifacts for build {}", artifacts.len(), req.build_id);
} else {
error!("Failed to upload artifacts: {}", resp.status());
}
}
Err(e) => error!("Failed to upload artifacts: {}", e),
}
}
Json(BuildResponse {
status: "success".to_string(),
message: String::new(),
artifacts,
})
} else {
error!("Build {} failed", req.package_name);
Json(BuildResponse {
status: "failed".to_string(),
message: String::new(),
artifacts: Vec::new(),
})
}
}
Err(e) => {
error!("Failed to wait for build: {}", e);
Json(BuildResponse {
status: "error".to_string(),
message: e.to_string(),
artifacts: Vec::new(),
})
}
};
state.building.store(false, Ordering::SeqCst);
result
}
async fn send_heartbeat(config: &AgentConfig) {
let client = reqwest::Client::new();
let (cpu, mem, mem_total, disk) = get_system_stats();
let arch = get_host_arch();
let body = serde_json::json!({
"name": config.name,
"hostname": config.hostname,
"port": config.port,
"arch": arch,
"cpu_usage": cpu,
"memory_usage": mem,
"memory_total_mb": mem_total,
"disk_usage": disk,
});
let url = format!("{}/api/heartbeat", config.master_url);
match client.post(&url).json(&body).send().await {
Ok(_) => info!("Heartbeat sent"),
Err(e) => error!("Failed to send heartbeat: {}", e),
}
}
async fn register(config: &AgentConfig) {
let client = reqwest::Client::new();
let (cpu, mem, mem_total, disk) = get_system_stats();
let arch = get_host_arch();
let body = serde_json::json!({
"name": config.name,
"hostname": config.hostname,
"port": config.port,
"arch": arch,
"cpu_usage": cpu,
"memory_usage": mem,
"memory_total_mb": mem_total,
"disk_usage": disk,
});
let url = format!("{}/api/register", config.master_url);
match client.post(&url).json(&body).send().await {
Ok(_) => info!("Registered with master"),
Err(e) => error!("Failed to register: {}", e),
}
}
struct AgentState {
config: AgentConfig,
building: Arc<AtomicBool>,
}
impl Clone for AgentState {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
building: self.building.clone(),
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into())
)
.init();
let config_path = std::env::var("AGENT_CONFIG").unwrap_or_else(|_| "agent.toml".to_string());
let config: AgentConfig = toml::from_str(&std::fs::read_to_string(&config_path)?)?;
info!("Starting build agent: {} on port {}", config.name, config.port);
info!("Clean interval: {}s", config.clean_interval_secs);
register(&config).await;
let building = Arc::new(AtomicBool::new(false));
// Heartbeat loop
let config_clone = config.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
loop {
interval.tick().await;
send_heartbeat(&config_clone).await;
}
});
// Idle clean loop
let clean_building = building.clone();
let clean_config = config.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(clean_config.clean_interval_secs));
loop {
interval.tick().await;
if !clean_building.load(Ordering::SeqCst) {
run_clean(&clean_config.ports_repo_path);
} else {
info!("Skipping clean: agent is building");
}
}
});
let state = AgentState {
config: config.clone(),
building,
};
let app = Router::new()
.route("/build", post(handle_build))
.with_state(state);
let addr = format!("0.0.0.0:{}", config.port);
info!("Agent listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
+49
View File
@@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
pkgbuild_hash TEXT,
description TEXT DEFAULT '',
last_built_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'building', 'built', 'failed')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS builds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_name TEXT NOT NULL,
target TEXT NOT NULL DEFAULT 'x86_64-semios-linux',
atombuild INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('queued', 'running', 'success', 'failed', 'cancelled')),
machine_id INTEGER,
trigger TEXT NOT NULL DEFAULT 'manual' CHECK(trigger IN ('manual', 'auto', 'hash-change')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME,
finished_at DATETIME,
log_path TEXT,
error_message TEXT,
FOREIGN KEY (machine_id) REFERENCES machines(id)
);
CREATE TABLE IF NOT EXISTS machines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
hostname TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 3001,
is_primary INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'offline' CHECK(status IN ('online', 'offline', 'building')),
current_build_id INTEGER,
cpu_usage REAL DEFAULT 0.0,
memory_usage REAL DEFAULT 0.0,
memory_total_mb REAL DEFAULT 0.0,
disk_usage REAL DEFAULT 0.0,
last_heartbeat DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (current_build_id) REFERENCES builds(id)
);
CREATE INDEX IF NOT EXISTS idx_builds_status ON builds(status);
CREATE INDEX IF NOT EXISTS idx_builds_package ON builds(package_name);
CREATE INDEX IF NOT EXISTS idx_builds_machine ON builds(machine_id);
CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status);
CREATE INDEX IF NOT EXISTS idx_machines_status ON machines(status);
+3247
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "semios-build-server",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "node build/index.js"
},
"dependencies": {
"better-sqlite3": "^11.8.2",
"toml": "^3.0.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/postcss": "^4.1.4",
"@types/better-sqlite3": "^7.6.13",
"autoprefixer": "^10.4.21",
"svelte": "^5.28.2",
"svelte-check": "^4.1.7",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {}
}
};
+34
View File
@@ -0,0 +1,34 @@
@import "tailwindcss";
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e1e4ed;
--text-muted: #8b8fa3;
--primary: #6366f1;
--primary-hover: #818cf8;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--primary-hover);
}
+9
View File
@@ -0,0 +1,9 @@
declare global {
namespace App {
interface Locals {
user?: { username: string };
}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semios Build Server</title>
%sveltekit.head%
</head>
<body>
<div>%sveltekit.body%</div>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
import type { Handle } from '@sveltejs/kit';
import { startDispatcher } from '$lib/server/dispatcher.js';
import { startSyncTimer, ensureRepo } from '$lib/server/git.js';
import { getConfig } from '$lib/server/config.js';
import { getDb } from '$lib/server/db.js';
import { validateSession } from '$lib/server/auth.js';
let started = false;
if (!started) {
started = true;
getDb();
console.log('Database initialized');
try {
const config = getConfig();
ensureRepo(config.ports.repo_url, config.ports.repo_path);
} catch (e) {
console.error('Failed to ensure repo:', (e as Error).message);
}
startDispatcher();
startSyncTimer();
console.log('Background tasks started');
}
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session');
if (token) {
const username = validateSession(token);
if (username) {
event.locals.user = { username };
}
}
return resolve(event);
};
+62
View File
@@ -0,0 +1,62 @@
const BASE = '/api';
async function request(path: string, options?: RequestInit) {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Request failed');
}
return res.json();
}
export async function fetchPackages() {
return request('/packages');
}
export async function fetchPackage(name: string) {
return request(`/packages/${encodeURIComponent(name)}`);
}
export async function triggerBuild(name: string, arch?: string, atombuild = true) {
return request(`/packages/${encodeURIComponent(name)}/build`, {
method: 'POST',
body: JSON.stringify({ package_name: name, arch, atombuild, trigger: 'manual' }),
});
}
export async function fetchBuilds(status?: string, limit?: number) {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (limit) params.set('limit', limit.toString());
return request(`/builds?${params}`);
}
export async function fetchBuild(id: number) {
return request(`/builds/${id}`);
}
export async function cancelBuild(id: number) {
return request(`/builds/${id}/cancel`, { method: 'POST' });
}
export async function fetchMachines() {
return request('/machines');
}
export async function addToRepo(buildIds: number[]) {
return request('/repo/add', {
method: 'POST',
body: JSON.stringify({ build_ids: buildIds }),
});
}
export async function syncPackages() {
return request('/sync', { method: 'POST' });
}
export function streamBuildLogs(buildId: number): EventSource {
return new EventSource(`${BASE}/builds/${buildId}/logs`);
}
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import { onMount } from 'svelte';
let {
value = $bindable(''),
options = [],
placeholder = 'Select...',
disabled = false,
}: {
value: string;
options: { value: string; label: string }[];
placeholder?: string;
disabled?: boolean;
} = $props();
let open = $state(false);
let buttonEl: HTMLButtonElement;
const selectedLabel = $derived(options.find(o => o.value === value)?.label ?? placeholder);
function select(v: string) {
value = v;
open = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
buttonEl?.focus();
}
}
onMount(() => {
function handleClickOutside(e: MouseEvent) {
if (open && buttonEl && !buttonEl.parentElement?.contains(e.target as Node)) {
open = false;
}
}
document.addEventListener('click', handleClickOutside, true);
return () => document.removeEventListener('click', handleClickOutside, true);
});
</script>
<div class="relative">
<button
bind:this={buttonEl}
{disabled}
onclick={() => open = !open}
class="flex items-center justify-between w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm text-left transition-colors hover:border-[var(--primary)] disabled:opacity-50 disabled:cursor-not-allowed {open ? 'border-[var(--primary)] ring-1 ring-[var(--primary)]' : ''}"
>
<span class="truncate {value ? 'text-[var(--text)]' : 'text-[var(--text-muted)]'}">{selectedLabel}</span>
<svg class="ml-2 h-4 w-4 shrink-0 text-[var(--text-muted)] transition-transform {open ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
</button>
{#if open}
<div
role="listbox"
onkeydown={handleKeydown}
class="absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-xl"
>
{#each options as opt}
<button
role="option"
aria-selected={opt.value === value}
onclick={() => select(opt.value)}
class="w-full px-3 py-2 text-sm text-left transition-colors hover:bg-[var(--primary)] hover:text-white {opt.value === value ? 'bg-[var(--primary)] text-white' : 'text-[var(--text)]'}"
>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
+50
View File
@@ -0,0 +1,50 @@
import crypto from 'node:crypto';
import { getConfig } from './config.js';
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
interface Session {
username: string;
expiresAt: number;
}
const sessions = new Map<string, Session>();
function verifyPassword(password: string, stored: string): boolean {
if (stored.startsWith('scrypt$')) {
const [, salt, hash] = stored.split('$');
const derived = crypto.scryptSync(password, salt, 64).toString('hex');
return derived === hash;
}
return false;
}
export function login(username: string, password: string): string | null {
const config = getConfig();
const stored = config.users?.[username];
if (!stored || !verifyPassword(password, stored)) return null;
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, { username, expiresAt: Date.now() + SESSION_TTL_MS });
return token;
}
export function validateSession(token: string): string | null {
const session = sessions.get(token);
if (!session) return null;
if (Date.now() > session.expiresAt) {
sessions.delete(token);
return null;
}
return session.username;
}
export function logout(token: string): void {
sessions.delete(token);
}
export function generatePasswordHash(password: string): string {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
return `scrypt$${salt}$${hash}`;
}
+23
View File
@@ -0,0 +1,23 @@
import toml from 'toml';
import fs from 'node:fs';
import path from 'node:path';
const CONFIG_PATH = process.env.BUILD_SERVER_CONFIG ?? path.resolve('config.toml');
export interface Config {
server: { host: string; port: number };
ports: { repo_url: string; repo_path: string; sync_interval_secs: number };
repo: { publish_dir: string };
agent: { token: string; listen_port: number };
users: Record<string, string>;
}
let _config: Config | null = null;
export function getConfig(): Config {
if (!_config) {
const content = fs.readFileSync(CONFIG_PATH, 'utf-8');
_config = toml.parse(content) as Config;
}
return _config;
}
+309
View File
@@ -0,0 +1,309 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
let _db: Database.Database | null = null;
export function getDb(): Database.Database {
if (!_db) {
_db = new Database('build-server.db');
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
const migration = fs.readFileSync(
path.resolve('migrations/001_initial.sql'),
'utf-8'
);
_db.exec(migration);
// Add arch column if missing
try {
_db.exec("ALTER TABLE machines ADD COLUMN arch TEXT DEFAULT ''");
} catch {}
// Add build_artifacts table if missing
try {
_db.exec(`CREATE TABLE IF NOT EXISTS build_artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
build_id INTEGER NOT NULL,
filename TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME,
FOREIGN KEY (build_id) REFERENCES builds(id),
UNIQUE(build_id, filename)
)`);
} catch {}
// Add deleted_at column if missing
try {
_db.exec("ALTER TABLE build_artifacts ADD COLUMN deleted_at DATETIME");
} catch {}
// Add added_to_repo_at column to builds if missing
try {
_db.exec("ALTER TABLE builds ADD COLUMN added_to_repo_at DATETIME");
} catch {}
// Recreate packages table with new status values (pending→none, building removed, add partial_built)
const sampleStatus = (_db.prepare("SELECT status FROM packages LIMIT 1").get() as any)?.status;
if (sampleStatus === 'pending' || sampleStatus === 'building') {
_db.exec(`CREATE TABLE IF NOT EXISTS packages_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
pkgbuild_hash TEXT,
description TEXT DEFAULT '',
last_built_hash TEXT,
status TEXT NOT NULL DEFAULT 'none' CHECK(status IN ('none', 'partial_built', 'built', 'failed')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
_db.exec(`INSERT INTO packages_new (id, name, pkgbuild_hash, description, last_built_hash, status, created_at, updated_at)
SELECT id, name, pkgbuild_hash, description, last_built_hash,
CASE WHEN status = 'pending' OR status = 'building' THEN 'none' ELSE status END,
created_at, updated_at FROM packages`);
_db.exec("DROP TABLE packages");
_db.exec("ALTER TABLE packages_new RENAME TO packages");
_db.exec("CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status)");
}
}
return _db;
}
export interface Package {
id: number;
name: string;
pkgbuild_hash: string | null;
description: string;
last_built_hash: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface Build {
id: number;
package_name: string;
target: string;
atombuild: number;
status: string;
machine_id: number | null;
trigger: string;
created_at: string;
started_at: string | null;
finished_at: string | null;
log_path: string | null;
error_message: string | null;
added_to_repo_at: string | null;
}
export interface Machine {
id: number;
name: string;
hostname: string;
port: number;
arch: string;
is_primary: number;
status: string;
current_build_id: number | null;
cpu_usage: number;
memory_usage: number;
memory_total_mb: number;
disk_usage: number;
last_heartbeat: string | null;
created_at: string;
}
export function getPackages(): Package[] {
return getDb().prepare(
'SELECT id, name, pkgbuild_hash, description, last_built_hash, status, created_at, updated_at FROM packages ORDER BY name'
).all() as Package[];
}
export function getPackage(name: string): Package | undefined {
return getDb().prepare(
'SELECT id, name, pkgbuild_hash, description, last_built_hash, status, created_at, updated_at FROM packages WHERE name = ?'
).get(name) as Package | undefined;
}
export function insertPackage(name: string, hash: string, description: string): void {
getDb().prepare(
`INSERT INTO packages (name, pkgbuild_hash, description, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
pkgbuild_hash = excluded.pkgbuild_hash,
description = excluded.description,
updated_at = CURRENT_TIMESTAMP`
).run(name, hash, description);
}
export function updatePackageHash(name: string, hash: string): boolean {
const result = getDb().prepare(
'UPDATE packages SET pkgbuild_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ? AND (pkgbuild_hash IS NULL OR pkgbuild_hash != ?)'
).run(hash, name, hash);
return result.changes > 0;
}
export function markPackageBuilt(name: string, hash: string): void {
const db = getDb();
db.prepare(
'UPDATE packages SET last_built_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?'
).run(hash, name);
// Compute status from arch coverage
const registeredArchs = db.prepare(
"SELECT DISTINCT arch FROM machines WHERE arch != '' AND status = 'online'"
).all() as { arch: string }[];
if (registeredArchs.length === 0) {
// No machines registered — just mark as built
db.prepare(
"UPDATE packages SET status = 'built', updated_at = CURRENT_TIMESTAMP WHERE name = ?"
).run(name);
return;
}
const builtArchs = db.prepare(
`SELECT DISTINCT b.target FROM builds b
WHERE b.package_name = ? AND b.status = 'success'
AND b.target IN (SELECT DISTINCT arch FROM machines WHERE arch != '' AND status = 'online')`
).all(name) as { target: string }[];
const allBuilt = registeredArchs.every(ra => builtArchs.some(ba => ba.target === ra.arch));
const newStatus = allBuilt ? 'built' : 'partial_built';
db.prepare(
'UPDATE packages SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?'
).run(newStatus, name);
}
export function markPackageFailed(name: string): void {
const db = getDb();
const hasSuccess = db.prepare(
"SELECT 1 FROM builds WHERE package_name = ? AND status = 'success' LIMIT 1"
).get(name);
if (!hasSuccess) {
db.prepare(
"UPDATE packages SET status = 'failed', updated_at = CURRENT_TIMESTAMP WHERE name = ?"
).run(name);
}
}
export function createBuild(packageName: string, target: string, atombuild: boolean, trigger: string): number {
const result = getDb().prepare(
'INSERT INTO builds (package_name, target, atombuild, trigger, status) VALUES (?, ?, ?, ?, \'queued\')'
).run(packageName, target, atombuild ? 1 : 0, trigger);
return Number(result.lastInsertRowid);
}
const BUILD_FIELDS = 'id, package_name, target, atombuild, status, machine_id, trigger, created_at, started_at, finished_at, log_path, error_message, added_to_repo_at';
export function getBuilds(status?: string, limit = 50): Build[] {
if (status) {
return getDb().prepare(
`SELECT ${BUILD_FIELDS} FROM builds WHERE status = ? ORDER BY created_at DESC LIMIT ?`
).all(status, limit) as Build[];
}
return getDb().prepare(
`SELECT ${BUILD_FIELDS} FROM builds ORDER BY created_at DESC LIMIT ?`
).all(limit) as Build[];
}
export function getBuild(id: number): Build | undefined {
return getDb().prepare(
`SELECT ${BUILD_FIELDS} FROM builds WHERE id = ?`
).get(id) as Build | undefined;
}
export function updateBuildStatus(id: number, status: string, error?: string): void {
const db = getDb();
if (status === 'running') {
db.prepare('UPDATE builds SET status = ?, started_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, id);
} else if (status === 'success' || status === 'failed' || status === 'cancelled') {
db.prepare('UPDATE builds SET status = ?, finished_at = CURRENT_TIMESTAMP, error_message = ? WHERE id = ?').run(status, error ?? null, id);
} else {
db.prepare('UPDATE builds SET status = ? WHERE id = ?').run(status, id);
}
}
export function updateBuildLogPath(id: number, logPath: string): void {
getDb().prepare('UPDATE builds SET log_path = ? WHERE id = ?').run(logPath, id);
}
export function assignBuildToMachine(buildId: number, machineId: number): void {
const db = getDb();
db.prepare('UPDATE builds SET machine_id = ? WHERE id = ?').run(machineId, buildId);
db.prepare('UPDATE machines SET status = \'building\', current_build_id = ? WHERE id = ?').run(buildId, machineId);
}
export function getNextQueuedBuild(): Build | undefined {
return getDb().prepare(
`SELECT ${BUILD_FIELDS} FROM builds WHERE status = 'queued' ORDER BY created_at ASC LIMIT 1`
).get() as Build | undefined;
}
export function getAvailableMachine(): Machine | undefined {
return getDb().prepare(
'SELECT id, name, hostname, port, arch, is_primary, status, current_build_id, cpu_usage, memory_usage, memory_total_mb, disk_usage, last_heartbeat, created_at FROM machines WHERE status = \'online\' ORDER BY cpu_usage ASC LIMIT 1'
).get() as Machine | undefined;
}
export function getAvailableMachineByArch(arch: string): Machine | undefined {
return getDb().prepare(
'SELECT id, name, hostname, port, arch, is_primary, status, current_build_id, cpu_usage, memory_usage, memory_total_mb, disk_usage, last_heartbeat, created_at FROM machines WHERE status = \'online\' AND arch = ? ORDER BY cpu_usage ASC LIMIT 1'
).get(arch) as Machine | undefined;
}
export function getDistinctArchs(): string[] {
const rows = getDb().prepare(
'SELECT DISTINCT arch FROM machines WHERE arch != \'\' AND status = \'online\' ORDER BY arch'
).all() as { arch: string }[];
return rows.map(r => r.arch);
}
export function releaseMachine(machineId: number): void {
getDb().prepare('UPDATE machines SET status = \'online\', current_build_id = NULL WHERE id = ?').run(machineId);
}
export function upsertMachine(name: string, hostname: string, port: number, arch: string = ''): number {
const result = getDb().prepare(
`INSERT INTO machines (name, hostname, port, arch, status, last_heartbeat)
VALUES (?, ?, ?, ?, 'online', CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
hostname = excluded.hostname, port = excluded.port, arch = excluded.arch, status = 'online', last_heartbeat = CURRENT_TIMESTAMP`
).run(name, hostname, port, arch);
return Number(result.lastInsertRowid);
}
export function updateMachineStats(name: string, cpu: number, mem: number, memTotal: number, disk: number): void {
getDb().prepare(
'UPDATE machines SET cpu_usage = ?, memory_usage = ?, memory_total_mb = ?, disk_usage = ?, last_heartbeat = CURRENT_TIMESTAMP, status = \'online\' WHERE name = ?'
).run(cpu, mem, memTotal, disk, name);
}
export function getMachines(): Machine[] {
return getDb().prepare(
'SELECT id, name, hostname, port, arch, is_primary, status, current_build_id, cpu_usage, memory_usage, memory_total_mb, disk_usage, last_heartbeat, created_at FROM machines ORDER BY is_primary DESC, name'
).all() as Machine[];
}
export function markBuildAddedToRepo(buildId: number): void {
getDb().prepare(
'UPDATE builds SET added_to_repo_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(buildId);
}
export function getUnpublishedBuildIds(): Array<{ id: number; package_name: string; target: string }> {
return getDb().prepare(
`SELECT id, package_name, target FROM builds
WHERE status = 'success' AND added_to_repo_at IS NULL
ORDER BY package_name, created_at DESC`
).all() as Array<{ id: number; package_name: string; target: string }>;
}
export function getLatestUnpublishedBuildPerPackage(): Array<{ id: number; package_name: string; target: string }> {
return getDb().prepare(
`SELECT b.id, b.package_name, b.target FROM builds b
INNER JOIN (
SELECT package_name, MAX(created_at) as max_created
FROM builds
WHERE status = 'success' AND added_to_repo_at IS NULL
GROUP BY package_name
) latest ON b.package_name = latest.package_name AND b.created_at = latest.max_created
WHERE b.status = 'success' AND b.added_to_repo_at IS NULL
ORDER BY b.package_name`
).all() as Array<{ id: number; package_name: string; target: string }>;
}
+155
View File
@@ -0,0 +1,155 @@
import { getConfig } from './config.js';
import {
getDb,
getNextQueuedBuild,
getAvailableMachine,
getAvailableMachineByArch,
assignBuildToMachine,
updateBuildStatus,
updateBuildLogPath,
releaseMachine,
markPackageBuilt,
markPackageFailed,
getBuild
} from './db.js';
import { broadcaster } from './sse.js';
import fs from 'node:fs';
import path from 'node:path';
interface AgentBuildResponse {
status: string;
message: string;
artifacts: string[];
}
export function startDispatcher(): void {
try {
const db = getDb();
db.prepare(
`UPDATE machines SET status = 'online', current_build_id = NULL
WHERE status = 'building' AND current_build_id IS NOT NULL
AND current_build_id NOT IN (SELECT id FROM builds WHERE status = 'running')`
).run();
} catch {}
setInterval(async () => {
try {
const build = getNextQueuedBuild();
if (!build) return;
// Try to find a machine matching the build's target arch
const buildArch = build.target?.split('-')[0] ?? '';
let machine = buildArch ? getAvailableMachineByArch(buildArch) : undefined;
if (!machine) {
machine = getAvailableMachine();
}
if (!machine) {
return;
}
assignBuildToMachine(build.id, machine.id);
const target = machine.arch || build.target;
getDb().prepare('UPDATE builds SET target = ? WHERE id = ?').run(target, build.id);
updateBuildStatus(build.id, 'running');
executeBuild(build.id, build.package_name, target, build.atombuild !== 0, machine.name, machine.hostname, machine.port, machine.arch);
} catch (e) {
console.error('Dispatcher error:', (e as Error).message);
}
}, 5000);
}
async function executeBuild(
buildId: number,
packageName: string,
target: string,
atombuild: boolean,
machineName: string,
hostname: string,
port: number,
arch: string
): Promise<void> {
const logDir = 'logs';
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, `build-${buildId}.log`);
updateBuildLogPath(buildId, logPath);
const agentUrl = `http://${hostname}:${port}`;
const send = (msg: string) => broadcaster.send(buildId, msg);
send(`Dispatching build to ${machineName} (${agentUrl}) [${arch}]...`);
send(`Package: ${packageName}`);
send(`Target: ${target}`);
send(`Atombuild: ${atombuild}`);
send(`Waiting for agent to start build...`);
send('');
// Heartbeat every 5s while waiting for agent
let alive = true;
const heartbeat = setInterval(() => {
if (alive) send('...');
}, 5000);
let resp: Response;
try {
resp = await fetch(`${agentUrl}/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
build_id: buildId,
package_name: packageName,
target,
atombuild
})
});
} catch (e) {
alive = false;
clearInterval(heartbeat);
const errMsg = `Failed to connect to agent: ${(e as Error).message}`;
console.error(errMsg);
const build = getBuild(buildId);
updateBuildStatus(buildId, 'failed', errMsg);
if (build?.machine_id) releaseMachine(build.machine_id);
send('');
send(`ERROR: ${errMsg}`);
broadcaster.remove(buildId);
return;
}
alive = false;
clearInterval(heartbeat);
let agentResp: AgentBuildResponse;
try {
agentResp = await resp.json() as AgentBuildResponse;
} catch (e) {
const errMsg = `Failed to parse agent response: ${(e as Error).message}`;
console.error(errMsg);
const build = getBuild(buildId);
updateBuildStatus(buildId, 'failed', errMsg);
if (build?.machine_id) releaseMachine(build.machine_id);
send('');
send(`ERROR: ${errMsg}`);
broadcaster.remove(buildId);
return;
}
// Agent now streams logs directly via POST /api/builds/:id/log
// No need to write log file or stream from here
const finalStatus = agentResp.status === 'success' ? 'success' : 'failed';
updateBuildStatus(buildId, finalStatus);
const build = getBuild(buildId);
if (build?.machine_id) releaseMachine(build.machine_id);
if (finalStatus === 'success') {
markPackageBuilt(packageName, packageName);
} else {
markPackageFailed(packageName);
}
send('');
send(`BUILD COMPLETE: ${packageName} - ${finalStatus}`);
broadcaster.remove(buildId);
console.log(`Build ${buildId} completed with status: ${finalStatus}`);
}
+100
View File
@@ -0,0 +1,100 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { getConfig } from './config.js';
import { insertPackage, updatePackageHash } from './db.js';
export function ensureRepo(repoUrl: string, repoPath: string): void {
const gitDir = path.join(repoPath, '.git');
if (fs.existsSync(gitDir)) {
console.log(`Pulling latest changes in ${repoPath}`);
try {
execSync('git pull --ff-only', { cwd: repoPath, stdio: 'pipe' });
} catch (e) {
console.error('git pull failed:', (e as Error).message);
}
} else {
console.log(`Cloning ${repoUrl} to ${repoPath}`);
const parent = path.dirname(repoPath);
fs.mkdirSync(parent, { recursive: true });
execSync(`git clone ${repoUrl} ${repoPath}`, { cwd: parent, stdio: 'pipe' });
}
}
export function hashPkgbuild(repoPath: string, packageName: string): string | null {
const pkgbuildPath = path.join(repoPath, 'packages', packageName, 'pkgbuild.py');
try {
const content = fs.readFileSync(pkgbuildPath);
return crypto.createHash('sha3-256').update(content).digest('hex');
} catch {
return null;
}
}
export function listPackages(repoPath: string): string[] {
const packagesDir = path.join(repoPath, 'packages');
try {
const entries = fs.readdirSync(packagesDir, { withFileTypes: true });
const packages = entries
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.filter(e => fs.existsSync(path.join(packagesDir, e.name, 'pkgbuild.py')))
.map(e => e.name);
packages.sort();
return packages;
} catch {
return [];
}
}
export function extractDescription(repoPath: string, packageName: string): string {
const pkgbuildPath = path.join(repoPath, 'packages', packageName, 'pkgbuild.py');
try {
const content = fs.readFileSync(pkgbuildPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('description(') && trimmed.endsWith(')')) {
const inner = trimmed.slice(12, -1).trim().replace(/^["']|["']$/g, '');
return inner;
}
}
} catch {}
return '';
}
export function repoAdd(publishDir: string, pkgFile: string): void {
execSync(`packie repo-admin --add-package ${pkgFile} ${publishDir}`, { stdio: 'pipe' });
}
export function syncPackages(): { total: number; newCount: number; updatedCount: number } {
const config = getConfig();
ensureRepo(config.ports.repo_url, config.ports.repo_path);
const packages = listPackages(config.ports.repo_path);
let newCount = 0;
let updatedCount = 0;
for (const pkgName of packages) {
const hash = hashPkgbuild(config.ports.repo_path, pkgName) ?? '';
const desc = extractDescription(config.ports.repo_path, pkgName);
insertPackage(pkgName, hash, desc);
if (updatePackageHash(pkgName, hash)) {
updatedCount++;
} else {
newCount++;
}
}
return { total: packages.length, newCount, updatedCount };
}
export function startSyncTimer(): void {
const config = getConfig();
setInterval(() => {
console.log('Auto-syncing packages...');
try {
syncPackages();
} catch (e) {
console.error('Sync failed:', (e as Error).message);
}
}, config.ports.sync_interval_secs * 1000);
}
+45
View File
@@ -0,0 +1,45 @@
type Listener = (msg: string) => void;
class Broadcaster {
private channels = new Map<number, { listeners: Set<Listener>; buffer: string[] }>();
private getOrCreate(buildId: number) {
let ch = this.channels.get(buildId);
if (!ch) {
ch = { listeners: new Set(), buffer: [] };
this.channels.set(buildId, ch);
}
return ch;
}
subscribe(buildId: number, cb: Listener): () => void {
const ch = this.getOrCreate(buildId);
ch.listeners.add(cb);
// Flush buffered messages to new subscriber
for (const msg of ch.buffer) {
try { cb(msg); } catch {}
}
return () => {
ch.listeners.delete(cb);
if (ch.listeners.size === 0 && ch.buffer.length > 0) {
// Keep buffer for potential late subscribers until remove() is called
}
};
}
send(buildId: number, msg: string): void {
const ch = this.getOrCreate(buildId);
ch.buffer.push(msg);
for (const cb of ch.listeners) {
try { cb(msg); } catch {}
}
}
remove(buildId: number): void {
this.channels.delete(buildId);
}
}
export const broadcaster = new Broadcaster();
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user ?? null,
};
};
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
let { children, data } = $props();
const navItems = [
{ path: '/', label: 'Dashboard', icon: '📊' },
{ path: '/packages', label: 'Packages', icon: '📦' },
{ path: '/builds', label: 'Builds', icon: '🔨' },
{ path: '/machines', label: 'Machines', icon: '🖥️' },
];
function isActive(path: string, current: string) {
if (path === '/') return current === '/';
return current.startsWith(path);
}
async function handleLogout() {
await fetch('/api/logout', { method: 'POST' });
goto('/login');
}
</script>
<div class="flex h-screen">
<nav class="w-56 bg-[var(--surface)] border-r border-[var(--border)] flex flex-col">
<div class="p-4 border-b border-[var(--border)]">
<h1 class="text-lg font-bold text-[var(--primary)]">Semios Build Server</h1>
</div>
<div class="flex-1 py-2">
{#each navItems as item}
{@const current = $page.url.pathname}
<a
href={item.path}
class="flex items-center gap-3 px-4 py-2.5 mx-2 rounded-lg text-sm transition-colors
{isActive(item.path, current)
? 'bg-[var(--primary)] text-white'
: 'text-[var(--text-muted)] hover:bg-[var(--border)] hover:text-[var(--text)]'}"
>
<span>{item.icon}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
{#if data.user}
<div class="p-4 border-t border-[var(--border)] flex items-center justify-between">
<span class="text-xs text-[var(--text-muted)] truncate">{data.user.username}</span>
<button
onclick={handleLogout}
class="text-xs text-[var(--text-muted)] hover:text-red-400 transition-colors"
>
Logout
</button>
</div>
{:else}
<div class="p-4 border-t border-[var(--border)]">
<a href="/login" class="text-xs text-[var(--primary)] hover:text-[var(--primary-hover)]">Login</a>
</div>
{/if}
</nav>
<main class="flex-1 overflow-auto p-6">
{@render children()}
</main>
</div>
+9
View File
@@ -0,0 +1,9 @@
import type { PageServerLoad } from './$types';
import { getPackages, getBuilds, getMachines } from '$lib/server/db.js';
export const load: PageServerLoad = async () => {
const packages = getPackages();
const builds = getBuilds(undefined, 10);
const machines = getMachines();
return { packages, builds, machines };
};
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
let { data } = $props();
function statusColor(status: string) {
switch (status) {
case 'online': return 'text-green-400';
case 'offline': return 'text-red-400';
case 'building': return 'text-yellow-400';
case 'success': return 'text-green-400';
case 'failed': return 'text-red-400';
case 'running': return 'text-yellow-400';
case 'queued': return 'text-blue-400';
default: return 'text-gray-400';
}
}
</script>
<div class="max-w-6xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div class="bg-[var(--surface)] rounded-xl p-4 border border-[var(--border)]">
<div class="text-sm text-[var(--text-muted)]">Total Packages</div>
<div class="text-3xl font-bold mt-1">{data.packages.length}</div>
</div>
<div class="bg-[var(--surface)] rounded-xl p-4 border border-[var(--border)]">
<div class="text-sm text-[var(--text-muted)]">Active Builds</div>
<div class="text-3xl font-bold mt-1 text-yellow-400">
{data.builds.filter((b: any) => b.status === 'running' || b.status === 'queued').length}
</div>
</div>
<div class="bg-[var(--surface)] rounded-xl p-4 border border-[var(--border)]">
<div class="text-sm text-[var(--text-muted)]">Machines Online</div>
<div class="text-3xl font-bold mt-1 text-green-400">
{data.machines.filter((m: any) => m.status === 'online' || m.status === 'building').length}
<span class="text-lg text-[var(--text-muted)]">/ {data.machines.length}</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h2 class="text-lg font-semibold mb-3">Recent Builds</h2>
<div class="space-y-2">
{#each data.builds.slice(0, 8) as build}
<a href="/builds/{build.id}"
class="block bg-[var(--surface)] rounded-lg p-3 border border-[var(--border)] hover:border-[var(--primary)] transition">
<div class="flex justify-between items-center">
<span class="font-mono text-sm">{build.package_name}</span>
<span class="text-xs px-2 py-0.5 rounded bg-opacity-20 {statusColor(build.status)}">
{build.status}
</span>
</div>
<div class="text-xs text-[var(--text-muted)] mt-1">
{build.target} · {build.trigger} · {new Date(build.created_at).toLocaleString()}
</div>
</a>
{/each}
{#if data.builds.length === 0}
<p class="text-[var(--text-muted)] text-sm">No builds yet</p>
{/if}
</div>
</div>
<div>
<h2 class="text-lg font-semibold mb-3">Build Machines</h2>
<div class="space-y-2">
{#each data.machines as machine}
<div class="bg-[var(--surface)] rounded-lg p-3 border border-[var(--border)]">
<div class="flex justify-between items-center">
<div>
<span class="font-semibold">{machine.name}</span>
{#if machine.is_primary}
<span class="text-xs ml-1 px-1.5 py-0.5 bg-[var(--primary)] rounded text-white">primary</span>
{/if}
</div>
<span class="text-xs {statusColor(machine.status)}">{machine.status}</span>
</div>
<div class="mt-2 grid grid-cols-2 gap-2 text-xs text-[var(--text-muted)]">
<div>CPU: {machine.cpu_usage.toFixed(1)}%</div>
<div>RAM: {machine.memory_usage.toFixed(1)}%</div>
</div>
<div class="mt-1 text-xs text-[var(--text-muted)]">{machine.hostname}:{machine.port} · {machine.arch || 'unknown'}</div>
</div>
{/each}
{#if data.machines.length === 0}
<p class="text-[var(--text-muted)] text-sm">No machines registered</p>
{/if}
</div>
</div>
</div>
</div>
+9
View File
@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import { getBuilds } from '$lib/server/db.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const status = url.searchParams.get('status') ?? undefined;
const limit = parseInt(url.searchParams.get('limit') ?? '50');
return json(getBuilds(status, limit));
};
+11
View File
@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { getBuild } from '$lib/server/db.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const build = getBuild(Number(params.id));
if (!build) {
return json({ error: 'Build not found' }, { status: 404 });
}
return json(build);
};
@@ -0,0 +1,82 @@
import { json } from '@sveltejs/kit';
import { getBuild, getDb } from '$lib/server/db.js';
import fs from 'node:fs';
import path from 'node:path';
import type { RequestHandler } from './$types';
const UPLOADS_DIR = 'uploads';
function getArtifactsDir(buildId: number): string {
return path.join(UPLOADS_DIR, `build-${buildId}`);
}
export const GET: RequestHandler = async ({ params }) => {
const buildId = Number(params.id);
const build = getBuild(buildId);
if (!build) return json({ error: 'Build not found' }, { status: 404 });
const db = getDb();
const rows = db.prepare(
'SELECT id, filename, created_at, deleted_at FROM build_artifacts WHERE build_id = ? ORDER BY filename'
).all(buildId) as { id: number; filename: string; created_at: string; deleted_at: string | null }[];
// Enrich with file size
const dir = getArtifactsDir(buildId);
const artifacts = rows.map(row => {
const deleted = !!row.deleted_at;
let size = 0;
if (!deleted) {
try { size = fs.statSync(path.join(dir, row.filename)).size; } catch {}
}
return { ...row, size, deleted };
});
return json(artifacts);
};
export const POST: RequestHandler = async ({ params, request }) => {
const buildId = Number(params.id);
const build = getBuild(buildId);
if (!build) return json({ error: 'Build not found' }, { status: 404 });
const body = await request.json();
if (!body?.files) return json({ error: 'No files provided' }, { status: 400 });
const dir = getArtifactsDir(buildId);
fs.mkdirSync(dir, { recursive: true });
const db = getDb();
const uploaded: string[] = [];
for (const file of body.files) {
const buffer = Buffer.from(file.content, 'base64');
fs.writeFileSync(path.join(dir, file.name), buffer);
db.prepare(
'INSERT INTO build_artifacts (build_id, filename) VALUES (?, ?) ON CONFLICT DO NOTHING'
).run(buildId, file.name);
uploaded.push(file.name);
}
return json({ status: 'ok', files: uploaded });
};
export const DELETE: RequestHandler = async ({ params, url, locals }) => {
if (!locals.user) {
return json({ error: 'Login required' }, { status: 401 });
}
const buildId = Number(params.id);
const filename = url.searchParams.get('filename');
if (!filename) return json({ error: 'filename required' }, { status: 400 });
const build = getBuild(buildId);
if (!build) return json({ error: 'Build not found' }, { status: 404 });
const filePath = path.join(getArtifactsDir(buildId), filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
const db = getDb();
db.prepare('UPDATE build_artifacts SET deleted_at = CURRENT_TIMESTAMP WHERE build_id = ? AND filename = ?').run(buildId, filename);
return json({ status: 'ok' });
};
@@ -0,0 +1,25 @@
import { error } from '@sveltejs/kit';
import { getBuild } from '$lib/server/db.js';
import fs from 'node:fs';
import path from 'node:path';
import type { RequestHandler } from './$types';
const UPLOADS_DIR = 'uploads';
export const GET: RequestHandler = async ({ params }) => {
const buildId = Number(params.id);
const filename = params.filename;
const build = getBuild(buildId);
if (!build) throw error(404, 'Build not found');
const filePath = path.join(UPLOADS_DIR, `build-${buildId}`, filename);
if (!fs.existsSync(filePath)) throw error(404, 'File not found');
const data = fs.readFileSync(filePath);
return new Response(data, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
};
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { updateBuildStatus } from '$lib/server/db.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params }) => {
try {
updateBuildStatus(Number(params.id), 'cancelled', 'Cancelled by user');
return json({ status: 'cancelled' });
} catch (e) {
return json({ error: (e as Error).message }, { status: 500 });
}
};
+21
View File
@@ -0,0 +1,21 @@
import { json } from '@sveltejs/kit';
import { broadcaster } from '$lib/server/sse.js';
import fs from 'node:fs';
import path from 'node:path';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request }) => {
const buildId = Number(params.id);
const { line } = await request.json();
// Broadcast to SSE subscribers
broadcaster.send(buildId, line);
// Append to log file
const logPath = path.join('logs', `build-${buildId}.log`);
try {
fs.appendFileSync(logPath, line + '\n');
} catch {}
return json({ status: 'ok' });
};
+12
View File
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { generatePasswordHash } from '$lib/server/auth.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
const { password } = await request.json();
if (!password) {
return json({ error: 'Password required' }, { status: 400 });
}
const hash = generatePasswordHash(password);
return json({ hash });
};
+14
View File
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import { upsertMachine, updateMachineStats } from '$lib/server/db.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const { name, hostname, port, arch, cpu_usage, memory_usage, memory_total_mb, disk_usage } = body;
console.log(`Heartbeat from ${name} (${arch || 'unknown'})`);
upsertMachine(name, hostname, port, arch ?? '');
updateMachineStats(name, cpu_usage, memory_usage, memory_total_mb, disk_usage);
return json({ status: 'ok' });
};
+24
View File
@@ -0,0 +1,24 @@
import { json, redirect } from '@sveltejs/kit';
import { login } from '$lib/server/auth.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, cookies }) => {
const { username, password } = await request.json();
if (!username || !password) {
return json({ error: 'Username and password required' }, { status: 400 });
}
const token = login(username, password);
if (!token) {
return json({ error: 'Invalid credentials' }, { status: 401 });
}
cookies.set('session', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60,
});
return json({ ok: true });
};
+10
View File
@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import { logout } from '$lib/server/auth.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies }) => {
const token = cookies.get('session');
if (token) logout(token);
cookies.delete('session', { path: '/' });
return json({ ok: true });
};
+7
View File
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { getMachines } from '$lib/server/db.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json(getMachines());
};
+7
View File
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { getPackages } from '$lib/server/db.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json(getPackages());
};
+11
View File
@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { getPackage } from '$lib/server/db.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const pkg = getPackage(params.name);
if (!pkg) {
return json({ error: 'Package not found' }, { status: 404 });
}
return json(pkg);
};
+13
View File
@@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import { upsertMachine } from '$lib/server/db.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const { name, hostname, port, arch } = body;
console.log(`Registering machine: ${name} (${arch || 'unknown'})`);
upsertMachine(name, hostname, port, arch ?? '');
return json({ status: 'ok', message: 'Machine registered' });
};
+68
View File
@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import { getConfig } from '$lib/server/config.js';
import { repoAdd } from '$lib/server/git.js';
import { getBuild, markBuildAddedToRepo } from '$lib/server/db.js';
import fs from 'node:fs';
import path from 'node:path';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
return json({ error: 'Login required' }, { status: 401 });
}
const body = await request.json();
const buildIds: number[] = body.build_ids ?? [];
const config = getConfig();
const results: Array<Record<string, string>> = [];
for (const buildId of buildIds) {
const build = getBuild(buildId);
if (!build) {
results.push({ build_id: String(buildId), status: 'error', message: 'Build not found' });
continue;
}
if (build.status !== 'success') {
results.push({ build_id: String(buildId), status: 'error', message: 'Build not successful' });
continue;
}
if (build.added_to_repo_at) {
results.push({ build_id: String(buildId), status: 'ok', message: 'Already added' });
continue;
}
const pkgName = build.package_name;
const artifactDir = path.join('uploads', `build-${buildId}`);
if (!fs.existsSync(artifactDir)) {
results.push({ build_id: String(buildId), package: pkgName, status: 'error', message: 'No artifacts found' });
continue;
}
let added = false;
try {
const entries = fs.readdirSync(artifactDir);
for (const entry of entries) {
if (entry.endsWith('.pkg')) {
const pkgFile = path.join(artifactDir, entry);
repoAdd(config.repo.publish_dir, pkgFile);
results.push({ build_id: String(buildId), package: pkgName, status: 'ok', file: entry });
added = true;
}
}
} catch (e) {
results.push({ build_id: String(buildId), package: pkgName, status: 'error', message: (e as Error).message });
}
if (!added && !results.some(r => r.build_id === String(buildId) && r.status === 'error')) {
results.push({ build_id: String(buildId), package: pkgName, status: 'error', message: 'No .pkg files found' });
}
if (added) {
markBuildAddedToRepo(buildId);
}
}
return json({ results });
};
+12
View File
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { syncPackages } from '$lib/server/git.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async () => {
try {
const result = syncPackages();
return json({ status: 'ok', ...result });
} catch (e) {
return json({ error: (e as Error).message }, { status: 500 });
}
};
+8
View File
@@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
import { getBuilds } from '$lib/server/db.js';
export const load: PageServerLoad = async ({ url }) => {
const status = url.searchParams.get('status') ?? undefined;
const builds = getBuilds(status, 100);
return { builds, currentStatus: status ?? '' };
};
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { cancelBuild } from '$lib/api.js';
import Select from '$lib/components/Select.svelte';
let { data } = $props();
let statusFilter = $state(data.currentStatus ?? '');
const statusOptions = [
{ value: '', label: 'All Status' },
{ value: 'queued', label: 'Queued' },
{ value: 'running', label: 'Running' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
];
let initialized = false;
$effect(() => {
if (initialized) {
const params = new URLSearchParams();
if (statusFilter) params.set('status', statusFilter);
goto(`/builds?${params}`);
}
initialized = true;
});
async function handleCancel(id: number) {
try {
await cancelBuild(id);
window.location.reload();
} catch (e) {
alert(`Cancel failed: ${e}`);
}
}
function statusBadge(status: string) {
const colors: Record<string, string> = {
queued: 'bg-yellow-900 text-yellow-300',
running: 'bg-blue-900 text-blue-300',
success: 'bg-green-900 text-green-300',
failed: 'bg-red-900 text-red-300',
cancelled: 'bg-gray-700 text-gray-300',
};
return colors[status] || 'bg-gray-700 text-gray-300';
}
</script>
<div class="max-w-6xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Builds</h1>
<Select bind:value={statusFilter} options={statusOptions} placeholder="All Status" />
</div>
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--border)] text-left text-sm text-[var(--text-muted)]">
<th class="px-4 py-3">#</th>
<th class="px-4 py-3">Package</th>
<th class="px-4 py-3">Target</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Trigger</th>
<th class="px-4 py-3">Machine</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each data.builds as build}
<tr class="border-b border-[var(--border)] hover:bg-[var(--border)] transition">
<td class="px-4 py-3 text-sm font-mono">
<a href="/builds/{build.id}" class="hover:text-[var(--primary-hover)]">{build.id}</a>
</td>
<td class="px-4 py-3">
<a href="/packages/{build.package_name}" class="font-mono text-sm font-semibold hover:text-[var(--primary-hover)]">
{build.package_name}
</a>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-muted)]">{build.target}</td>
<td class="px-4 py-3">
<span class="text-xs px-2 py-1 rounded {statusBadge(build.status)}">
{build.status}
</span>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-muted)]">{build.trigger}</td>
<td class="px-4 py-3 text-sm text-[var(--text-muted)]">{build.machine_id || '-'}</td>
<td class="px-4 py-3 text-xs text-[var(--text-muted)]">
{new Date(build.created_at).toLocaleString()}
</td>
<td class="px-4 py-3 text-right">
{#if build.status === 'queued' || build.status === 'running'}
<button
onclick={() => handleCancel(build.id)}
class="px-3 py-1 bg-red-600 text-white rounded text-xs hover:bg-red-500"
>
Cancel
</button>
{:else}
<a href="/builds/{build.id}" class="px-3 py-1 bg-[var(--border)] rounded text-xs hover:bg-gray-600">
View
</a>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{#if data.builds.length === 0}
<div class="p-8 text-center text-[var(--text-muted)]">No builds found</div>
{/if}
</div>
</div>
+39
View File
@@ -0,0 +1,39 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBuild, getDb } from '$lib/server/db.js';
import fs from 'node:fs';
import path from 'node:path';
const UPLOADS_DIR = 'uploads';
export const load: PageServerLoad = async ({ params }) => {
const build = getBuild(Number(params.id));
if (!build) {
error(404, 'Build not found');
}
let logContent = '';
if (build.log_path) {
try {
logContent = fs.readFileSync(build.log_path, 'utf-8');
} catch {}
}
// Load artifacts with size and deleted status
const db = getDb();
const rows = db.prepare(
'SELECT id, filename, created_at, deleted_at FROM build_artifacts WHERE build_id = ? ORDER BY filename'
).all(build.id) as { id: number; filename: string; created_at: string; deleted_at: string | null }[];
const dir = path.join(UPLOADS_DIR, `build-${build.id}`);
const artifacts = rows.map(row => {
const deleted = !!row.deleted_at;
let size = 0;
if (!deleted) {
try { size = fs.statSync(path.join(dir, row.filename)).size; } catch {}
}
return { ...row, size, deleted };
});
return { build, logContent, artifacts };
};
+194
View File
@@ -0,0 +1,194 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { cancelBuild, streamBuildLogs } from '$lib/api.js';
let { data } = $props();
let build = $state(data.build);
let logs = $state(data.logContent ?? '');
let artifacts = $state(data.artifacts ?? []);
let logContainer: HTMLElement | null = $state(null);
let eventSource: EventSource | null = null;
function statusBadge(status: string) {
const colors: Record<string, string> = {
queued: 'bg-yellow-900 text-yellow-300',
running: 'bg-blue-900 text-blue-300',
success: 'bg-green-900 text-green-300',
failed: 'bg-red-900 text-red-300',
cancelled: 'bg-gray-700 text-gray-300',
};
return colors[status] || 'bg-gray-700 text-gray-300';
}
function startLogStream() {
stopLogStream();
eventSource = streamBuildLogs(build.id);
eventSource.onmessage = (event) => {
logs += event.data + '\n';
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight;
}
};
eventSource.onerror = () => {
setTimeout(() => {
if (build.status === 'running' || build.status === 'queued') {
startLogStream();
}
}, 3000);
};
}
function stopLogStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
async function handleCancel() {
try {
await cancelBuild(build.id);
build = { ...build, status: 'cancelled', error_message: 'Cancelled by user', finished_at: new Date().toISOString() };
stopLogStream();
} catch (e) {
alert(`Cancel failed: ${e}`);
}
}
async function deleteArtifact(filename: string) {
if (!confirm(`Delete ${filename}?`)) return;
try {
const res = await fetch(`/api/builds/${build.id}/artifacts?filename=${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (res.ok) {
artifacts = artifacts.map(a => a.filename === filename ? { ...a, deleted: true } : a);
}
} catch (e) {
alert(`Delete failed: ${e}`);
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
onMount(() => {
if (build.status === 'running' || build.status === 'queued') {
startLogStream();
}
});
onDestroy(() => {
stopLogStream();
});
</script>
<div class="max-w-6xl mx-auto">
<div class="flex justify-between items-start mb-6">
<div>
<h1 class="text-2xl font-bold">
Build #{build.id}
<a href="/packages/{build.package_name}" class="text-[var(--primary)] ml-2">{build.package_name}</a>
</h1>
<div class="flex gap-4 mt-2 text-sm text-[var(--text-muted)]">
<span>Target: {build.target}</span>
<span>Trigger: {build.trigger}</span>
<span>{build.atombuild ? '--atombuild' : 'normal'}</span>
{#if build.machine_id}
<span>Machine: #{build.machine_id}</span>
{/if}
</div>
</div>
<div class="flex gap-2 items-center">
<span class="text-xs px-3 py-1 rounded {statusBadge(build.status)}">
{build.status}
</span>
{#if build.status === 'queued' || build.status === 'running'}
<button
onclick={handleCancel}
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 text-sm"
>
Cancel
</button>
{/if}
</div>
</div>
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-[var(--surface)] rounded-xl p-3 border border-[var(--border)]">
<div class="text-xs text-[var(--text-muted)]">Created</div>
<div class="text-sm mt-1">{new Date(build.created_at).toLocaleString()}</div>
</div>
<div class="bg-[var(--surface)] rounded-xl p-3 border border-[var(--border)]">
<div class="text-xs text-[var(--text-muted)]">Started</div>
<div class="text-sm mt-1">{build.started_at ? new Date(build.started_at).toLocaleString() : '-'}</div>
</div>
<div class="bg-[var(--surface)] rounded-xl p-3 border border-[var(--border)]">
<div class="text-xs text-[var(--text-muted)]">Finished</div>
<div class="text-sm mt-1">{build.finished_at ? new Date(build.finished_at).toLocaleString() : '-'}</div>
</div>
<div class="bg-[var(--surface)] rounded-xl p-3 border border-[var(--border)]">
<div class="text-xs text-[var(--text-muted)]">Error</div>
<div class="text-sm mt-1 {build.error_message ? 'text-red-400' : ''} truncate">{build.error_message || '-'}</div>
</div>
</div>
{#if artifacts.length > 0}
<h2 class="text-lg font-semibold mb-3">Build Artifacts</h2>
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] mb-6 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[var(--border)] text-[var(--text-muted)] text-xs">
<th class="text-left px-4 py-2">Filename</th>
<th class="text-left px-4 py-2">Size</th>
<th class="text-right px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>
{#each artifacts as artifact}
<tr class="border-b border-[var(--border)] last:border-0 hover:bg-[var(--bg)] {artifact.deleted ? 'opacity-40' : ''}">
<td class="px-4 py-2 font-mono text-xs">{artifact.filename}</td>
<td class="px-4 py-2 text-[var(--text-muted)]">{artifact.size ? formatSize(artifact.size) : '-'}</td>
<td class="px-4 py-2 text-right">
{#if artifact.deleted}
<span class="text-[var(--text-muted)] text-xs">Deleted</span>
{:else}
<a
href="/api/builds/{build.id}/artifacts/{artifact.filename}"
class="text-[var(--primary)] hover:underline text-xs mr-3"
download
>
Download
</a>
<button
onclick={() => deleteArtifact(artifact.filename)}
class="text-red-400 hover:text-red-300 text-xs"
>
Delete
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<h2 class="text-lg font-semibold mb-3">Build Log</h2>
<div
bind:this={logContainer}
class="bg-[#0d1017] rounded-xl border border-[var(--border)] p-4 h-96 overflow-y-auto font-mono text-xs leading-relaxed"
>
{#if logs}
<pre class="whitespace-pre-wrap">{logs}</pre>
{:else if build.status === 'running'}
<div class="text-[var(--text-muted)] animate-pulse">Waiting for logs...</div>
{:else if build.status === 'queued'}
<div class="text-[var(--text-muted)]">Build is queued, waiting to start...</div>
{:else}
<div class="text-[var(--text-muted)]">No log output available</div>
{/if}
</div>
</div>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import { goto } from '$app/navigation';
let username = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function handleLogin() {
error = '';
loading = true;
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
error = data.error || 'Login failed';
} else {
goto('/');
}
} catch (e) {
error = 'Connection failed';
} finally {
loading = false;
}
}
</script>
<div class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-sm mx-4">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-[var(--primary)]">Semios Build Server</h1>
<p class="text-[var(--text-muted)] text-sm mt-2">Sign in to continue</p>
</div>
<form
onsubmit={(e) => { e.preventDefault(); handleLogin(); }}
class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 space-y-4"
>
<div>
<label class="block text-sm text-[var(--text-muted)] mb-1">Username</label>
<input
type="text"
bind:value={username}
autofocus
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
<div>
<label class="block text-sm text-[var(--text-muted)] mb-1">Password</label>
<input
type="password"
bind:value={password}
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
{#if error}
<div class="text-sm text-red-400">{error}</div>
{/if}
<button
type="submit"
disabled={loading || !username || !password}
class="w-full py-2 bg-[var(--primary)] text-white rounded-lg text-sm hover:bg-[var(--primary-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p class="text-center text-xs text-[var(--text-muted)] mt-6">
Need to add a user? See the
<a href="/register" class="text-[var(--primary)] hover:text-[var(--primary-hover)]">registration helper</a>.
</p>
</div>
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
import { getMachines } from '$lib/server/db.js';
export const load: PageServerLoad = async () => {
const machines = getMachines();
return { machines };
};
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchMachines } from '$lib/api.js';
let { data } = $props();
let machines = $state(data.machines);
function statusColor(status: string) {
switch (status) {
case 'online': return 'text-green-400 bg-green-900';
case 'offline': return 'text-red-400 bg-red-900';
case 'building': return 'text-blue-400 bg-blue-900';
default: return 'text-gray-400 bg-gray-800';
}
}
function barColor(value: number) {
if (value > 90) return 'bg-red-500';
if (value > 70) return 'bg-yellow-500';
return 'bg-[var(--primary)]';
}
async function refresh() {
try {
machines = await fetchMachines();
} catch (e) {
console.error(e);
}
}
onMount(() => {
const interval = setInterval(refresh, 5000);
return () => clearInterval(interval);
});
</script>
<div class="max-w-6xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Build Machines</h1>
<button
onclick={refresh}
class="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-sm hover:bg-[var(--border)]"
>
Refresh
</button>
</div>
{#if machines.length === 0}
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-12 text-center">
<p class="text-[var(--text-muted)]">No machines registered yet.</p>
<p class="text-sm text-[var(--text-muted)] mt-2">
Start a build agent on each machine to register it.
</p>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each machines as machine}
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5">
<div class="flex justify-between items-start mb-4">
<div>
<div class="flex items-center gap-2">
<h3 class="font-semibold text-lg">{machine.name}</h3>
{#if machine.is_primary}
<span class="text-xs px-1.5 py-0.5 bg-[var(--primary)] rounded text-white">primary</span>
{/if}
</div>
<div class="text-sm text-[var(--text-muted)]">{machine.hostname}:{machine.port} · {machine.arch || 'unknown'}</div>
</div>
<span class="text-xs px-2 py-1 rounded {statusColor(machine.status)}">
{machine.status}
</span>
</div>
{#if machine.status !== 'offline'}
<div class="space-y-3">
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-[var(--text-muted)]">CPU</span>
<span>{machine.cpu_usage.toFixed(1)}%</span>
</div>
<div class="w-full bg-[var(--border)] rounded h-2">
<div class="h-2 rounded {barColor(machine.cpu_usage)} transition-all" style="width: {Math.min(machine.cpu_usage, 100)}%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-[var(--text-muted)]">Memory</span>
<span>{machine.memory_usage.toFixed(1)}% of {machine.memory_total_mb.toFixed(0)} MB</span>
</div>
<div class="w-full bg-[var(--border)] rounded h-2">
<div class="h-2 rounded {barColor(machine.memory_usage)} transition-all" style="width: {Math.min(machine.memory_usage, 100)}%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-[var(--text-muted)]">Disk</span>
<span>{machine.disk_usage.toFixed(1)}%</span>
</div>
<div class="w-full bg-[var(--border)] rounded h-2">
<div class="h-2 rounded {barColor(machine.disk_usage)} transition-all" style="width: {Math.min(machine.disk_usage, 100)}%"></div>
</div>
</div>
{#if machine.current_build_id}
<div class="pt-2 border-t border-[var(--border)]">
<div class="text-xs text-[var(--text-muted)]">Current Build</div>
<a href="/builds/{machine.current_build_id}" class="text-sm text-[var(--primary)] hover:text-[var(--primary-hover)]">
Build #{machine.current_build_id}
</a>
</div>
{/if}
</div>
{:else}
<div class="text-sm text-[var(--text-muted)] py-4 text-center">
Machine is offline
</div>
{/if}
<div class="mt-4 pt-3 border-t border-[var(--border)] text-xs text-[var(--text-muted)]">
Last heartbeat: {machine.last_heartbeat ? new Date(machine.last_heartbeat).toLocaleString() : 'Never'}
</div>
</div>
{/each}
</div>
{/if}
</div>
+15
View File
@@ -0,0 +1,15 @@
import type { PageServerLoad } from './$types';
import { getPackages, getLatestUnpublishedBuildPerPackage, getDistinctArchs } from '$lib/server/db.js';
import fs from 'node:fs';
import path from 'node:path';
export const load: PageServerLoad = async () => {
const packages = getPackages();
const unpublishedBuilds = getLatestUnpublishedBuildPerPackage().map(b => ({
...b,
has_artifacts: fs.existsSync(path.join('uploads', `build-${b.id}`)) &&
fs.readdirSync(path.join('uploads', `build-${b.id}`)).some(f => f.endsWith('.pkg'))
}));
const archs = getDistinctArchs();
return { packages, unpublishedBuilds, archs };
};
+337
View File
@@ -0,0 +1,337 @@
<script lang="ts">
import { triggerBuild, syncPackages, addToRepo } from '$lib/api.js';
import Select from '$lib/components/Select.svelte';
let { data } = $props();
let search = $state('');
let syncing = $state(false);
let syncResult = $state('');
let showRepoModal = $state(false);
let selectedBuildIds = $state<Set<number>>(new Set());
let adding = $state(false);
let repoResult = $state<Array<{ build_id: string; package?: string; status: string; file?: string; message?: string }> | null>(null);
let showBuildModal = $state(false);
let buildModalPkg = $state('');
let buildArch = $state('');
let buildAtombuild = $state(true);
let building = $state(false);
const archOptions = $derived(data.archs.map((a: string) => ({ value: a, label: a })));
const addableBuilds = $derived(data.unpublishedBuilds.filter((b: any) => b.has_artifacts));
const addableBuildIds = $derived(addableBuilds.map((b: any) => b.id));
const publishedPackageNames = $derived(
new Set(
data.packages
.filter((p: any) => p.status === 'built')
.filter((p: any) => !addableBuilds.some((b: any) => b.package_name === p.name))
.map((p: any) => p.name)
)
);
let filtered = $derived(
search
? data.packages.filter((p: any) => p.name.toLowerCase().includes(search.toLowerCase()))
: data.packages
);
function openRepoModal() {
repoResult = null;
selectedBuildIds = new Set(addableBuildIds);
showRepoModal = true;
}
function toggleBuild(id: number) {
const next = new Set(selectedBuildIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedBuildIds = next;
}
function toggleAll() {
if (selectedBuildIds.size === addableBuildIds.length) {
selectedBuildIds = new Set();
} else {
selectedBuildIds = new Set(addableBuildIds);
}
}
async function handleConfirmAddToRepo() {
const ids = [...selectedBuildIds];
if (ids.length === 0) return;
adding = true;
try {
const res = await addToRepo(ids);
repoResult = res.results;
} catch (e) {
alert(`Add to repo failed: ${e}`);
} finally {
adding = false;
}
}
function openBuildModal(name: string) {
buildModalPkg = name;
buildArch = data.archs[0] ?? '';
buildAtombuild = true;
showBuildModal = true;
}
async function handleBuild() {
building = true;
try {
await triggerBuild(buildModalPkg, buildArch, buildAtombuild);
showBuildModal = false;
window.location.reload();
} catch (e) {
alert(`Build failed: ${e}`);
} finally {
building = false;
}
}
async function handleSync() {
syncing = true;
syncResult = '';
try {
const result = await syncPackages();
syncResult = `Synced: ${result.total} packages, ${result.new} new, ${result.updated} updated`;
window.location.reload();
} catch (e) {
syncResult = `Sync failed: ${e}`;
} finally {
syncing = false;
}
}
</script>
{#if showRepoModal}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" onclick={() => showRepoModal = false}></div>
<div class="relative bg-[var(--surface)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[80vh] flex flex-col">
{#if repoResult}
<div class="px-6 py-4 border-b border-[var(--border)] flex justify-between items-center">
<h2 class="text-lg font-semibold">Add to Repository</h2>
<button onclick={() => { showRepoModal = false; window.location.reload(); }} class="text-[var(--text-muted)] hover:text-white text-xl">&times;</button>
</div>
<div class="overflow-y-auto flex-1 px-6 py-3">
{#each repoResult as r}
<div class="flex items-center gap-2 py-1.5 text-sm">
{#if r.status === 'ok'}
<span class="text-green-400">&#10003;</span>
<span class="text-green-300">{r.package || r.build_id}</span>
<span class="text-[var(--text-muted)] text-xs">{r.file || r.message}</span>
{:else}
<span class="text-red-400">&#10007;</span>
<span class="text-red-300">{r.package || r.build_id}</span>
<span class="text-[var(--text-muted)] text-xs">{r.message}</span>
{/if}
</div>
{/each}
</div>
<div class="px-6 py-4 border-t border-[var(--border)] flex justify-end">
<button
onclick={() => { showRepoModal = false; window.location.reload(); }}
class="px-4 py-2 bg-[var(--primary)] text-white rounded-lg text-sm hover:bg-[var(--primary-hover)]"
>
Done
</button>
</div>
{:else}
<div class="px-6 py-4 border-b border-[var(--border)] flex justify-between items-center">
<h2 class="text-lg font-semibold">Add to Repository</h2>
<button onclick={() => showRepoModal = false} class="text-[var(--text-muted)] hover:text-white text-xl">&times;</button>
</div>
<div class="px-6 py-3 border-b border-[var(--border)] flex items-center gap-3">
<input
type="checkbox"
checked={selectedBuildIds.size === addableBuildIds.length && addableBuildIds.length > 0}
onchange={toggleAll}
class="rounded"
/>
<span class="text-sm text-[var(--text-muted)]">
{selectedBuildIds.size} / {addableBuildIds.length} selected
</span>
</div>
<div class="overflow-y-auto flex-1 px-6 py-2">
{#each addableBuilds as build}
<label class="flex items-center gap-3 py-2 border-b border-[var(--border)] last:border-0 cursor-pointer hover:bg-[var(--border)] px-2 rounded transition">
<input
type="checkbox"
checked={selectedBuildIds.has(build.id)}
onchange={() => toggleBuild(build.id)}
class="rounded"
/>
<div class="flex-1 min-w-0">
<div class="font-mono text-sm font-semibold">{build.package_name}</div>
<div class="text-xs text-[var(--text-muted)]">Build #{build.id} &middot; {build.target}</div>
</div>
</label>
{/each}
{#if addableBuildIds.length === 0}
<div class="py-6 text-center text-[var(--text-muted)] text-sm">No packages with artifacts to add</div>
{/if}
</div>
<div class="px-6 py-4 border-t border-[var(--border)] flex justify-end gap-3">
<button
onclick={() => showRepoModal = false}
class="px-4 py-2 bg-[var(--border)] rounded-lg text-sm hover:bg-gray-600"
>
Cancel
</button>
<button
onclick={handleConfirmAddToRepo}
disabled={selectedBuildIds.size === 0 || adding}
class="px-4 py-2 bg-[var(--primary)] text-white rounded-lg text-sm hover:bg-[var(--primary-hover)] disabled:opacity-50"
>
{adding ? 'Adding...' : `Add ${selectedBuildIds.size} to Repo`}
</button>
</div>
{/if}
</div>
</div>
{/if}
{#if showBuildModal}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" onclick={() => showBuildModal = false}></div>
<div class="relative bg-[var(--surface)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-md mx-4">
<div class="px-6 py-4 border-b border-[var(--border)] flex justify-between items-center">
<h2 class="text-lg font-semibold">Build <span class="font-mono">{buildModalPkg}</span></h2>
<button onclick={() => showBuildModal = false} class="text-[var(--text-muted)] hover:text-white text-xl">&times;</button>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label class="block text-sm text-[var(--text-muted)] mb-1">Architecture</label>
{#if data.archs.length > 0}
<Select bind:value={buildArch} options={archOptions} />
{:else}
<div class="text-sm text-red-400">No machines online</div>
{/if}
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-[var(--text-muted)]">Atombuild</label>
<button
onclick={() => buildAtombuild = !buildAtombuild}
class="relative w-10 h-6 rounded-full transition-colors {buildAtombuild ? 'bg-[var(--primary)]' : 'bg-gray-600'}"
>
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform {buildAtombuild ? 'translate-x-4' : ''}"></span>
</button>
</div>
</div>
<div class="px-6 py-4 border-t border-[var(--border)] flex justify-end gap-3">
<button
onclick={() => showBuildModal = false}
class="px-4 py-2 bg-[var(--border)] rounded-lg text-sm hover:bg-gray-600"
>
Cancel
</button>
<button
onclick={handleBuild}
disabled={data.archs.length === 0 || building}
class="px-4 py-2 bg-[var(--primary)] text-white rounded-lg text-sm hover:bg-[var(--primary-hover)] disabled:opacity-50"
>
{building ? 'Building...' : 'Build'}
</button>
</div>
</div>
</div>
{/if}
<div class="max-w-6xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Packages</h1>
<div class="flex gap-2">
<button
onclick={handleSync}
disabled={syncing}
class="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-sm hover:bg-[var(--border)] disabled:opacity-50"
>
{syncing ? 'Syncing...' : 'Sync from Git'}
</button>
<button
onclick={openRepoModal}
disabled={addableBuildIds.length === 0}
class="px-4 py-2 bg-[var(--primary)] text-white rounded-lg text-sm hover:bg-[var(--primary-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
>
Add to Repo {#if addableBuildIds.length > 0}({addableBuildIds.length}){/if}
</button>
</div>
</div>
{#if syncResult}
<div class="mb-4 p-3 bg-[var(--surface)] rounded-lg border border-[var(--border)] text-sm">
{syncResult}
</div>
{/if}
<div class="mb-4">
<input
type="text"
bind:value={search}
placeholder="Search packages..."
class="w-full px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-lg text-sm"
/>
</div>
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--border)] text-left text-sm text-[var(--text-muted)]">
<th class="px-4 py-3">Package</th>
<th class="px-4 py-3">Description</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Repo</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each filtered as pkg}
{@const isPublished = publishedPackageNames.has(pkg.name)}
<tr class="border-b border-[var(--border)] hover:bg-[var(--border)] transition">
<td class="px-4 py-3">
<a href="/packages/{pkg.name}" class="font-mono text-sm font-semibold hover:text-[var(--primary-hover)]">
{pkg.name}
</a>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-muted)] truncate max-w-xs">{pkg.description || '-'}</td>
<td class="px-4 py-3">
<span class="text-xs px-2 py-1 rounded
{pkg.status === 'built' ? 'bg-green-900 text-green-300' :
pkg.status === 'partial_built' ? 'bg-yellow-900 text-yellow-300' :
pkg.status === 'failed' ? 'bg-red-900 text-red-300' :
'bg-gray-700 text-gray-300'}">
{pkg.status === 'partial_built' ? 'partial' : pkg.status}
</span>
</td>
<td class="px-4 py-3">
{#if pkg.status === 'built'}
{#if isPublished}
<span class="text-xs px-2 py-1 rounded bg-gray-700 text-gray-300">Added</span>
{:else}
<span class="text-xs px-2 py-1 rounded bg-blue-900 text-blue-300">Pending</span>
{/if}
{:else if pkg.status === 'partial_built'}
<span class="text-xs px-2 py-1 rounded bg-yellow-900 text-yellow-300">Partial</span>
{:else}
<span class="text-xs text-[var(--text-muted)]">-</span>
{/if}
</td>
<td class="px-4 py-3 text-right">
<button
onclick={() => openBuildModal(pkg.name)}
class="px-3 py-1 bg-[var(--primary)] text-white rounded text-xs hover:bg-[var(--primary-hover)]"
>
Build
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{#if filtered.length === 0}
<div class="p-8 text-center text-[var(--text-muted)]">No packages found</div>
{/if}
</div>
</div>
@@ -0,0 +1,14 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getPackage, getBuilds, getDistinctArchs } from '$lib/server/db.js';
export const load: PageServerLoad = async ({ params }) => {
const pkg = getPackage(params.name);
if (!pkg) {
error(404, 'Package not found');
}
const builds = getBuilds(undefined, 20);
const pkgBuilds = builds.filter(b => b.package_name === params.name);
const archs = getDistinctArchs();
return { pkg, pkgBuilds, archs };
};
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { triggerBuild } from '$lib/api.js';
import Select from '$lib/components/Select.svelte';
let { data } = $props();
let showBuildModal = $state(false);
let buildArch = $state(data.archs[0] ?? '');
let buildAtombuild = $state(true);
let building = $state(false);
const archOptions = $derived(data.archs.map((a: string) => ({ value: a, label: a })));
function openBuildModal() {
buildArch = data.archs[0] ?? '';
buildAtombuild = true;
showBuildModal = true;
}
async function handleBuild() {
building = true;
try {
await triggerBuild(data.pkg.name, buildArch, buildAtombuild);
showBuildModal = false;
window.location.reload();
} catch (e) {
alert(`Build failed: ${e}`);
} finally {
building = false;
}
}
</script>
{#if showBuildModal}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" onclick={() => showBuildModal = false}></div>
<div class="relative bg-[var(--surface)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-md mx-4">
<div class="px-6 py-4 border-b border-[var(--border)] flex justify-between items-center">
<h2 class="text-lg font-semibold">Build <span class="font-mono">{data.pkg.name}</span></h2>
<button onclick={() => showBuildModal = false} class="text-[var(--text-muted)] hover:text-white text-xl">&times;</button>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label class="block text-sm text-[var(--text-muted)] mb-1">Architecture</label>
{#if data.archs.length > 0}
<Select bind:value={buildArch} options={archOptions} />
{:else}
<div class="text-sm text-red-400">No machines online</div>
{/if}
</div>
<div class="flex items-center justify-between">
<label class="text-sm text-[var(--text-muted)]">Atombuild</label>
<button
onclick={() => buildAtombuild = !buildAtombuild}
class="relative w-10 h-6 rounded-full transition-colors {buildAtombuild ? 'bg-[var(--primary)]' : 'bg-gray-600'}"
>
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform {buildAtombuild ? 'translate-x-4' : ''}"></span>
</button>
</div>
</div>
<div class="px-6 py-4 border-t border-[var(--border)] flex justify-end gap-3">
<button
onclick={() => showBuildModal = false}
class="px-4 py-2 bg-[var(--border)] rounded-lg text-sm hover:bg-gray-600"
>
Cancel
</button>
<button
onclick={handleBuild}
disabled={data.archs.length === 0 || building}
class="px-4 py-2 bg-[var(--primary)] text-white rounded-lg text-sm hover:bg-[var(--primary-hover)] disabled:opacity-50"
>
{building ? 'Building...' : 'Build'}
</button>
</div>
</div>
</div>
{/if}
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-start mb-6">
<div>
<h1 class="text-2xl font-bold font-mono">{data.pkg.name}</h1>
<p class="text-[var(--text-muted)] mt-1">{data.pkg.description || 'No description'}</p>
</div>
<button
onclick={openBuildModal}
disabled={data.archs.length === 0}
class="px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:bg-[var(--primary-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
>
Build
</button>
</div>
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-[var(--surface)] rounded-xl p-4 border border-[var(--border)]">
<div class="text-sm text-[var(--text-muted)]">Status</div>
<div class="mt-1 font-semibold
{data.pkg.status === 'built' ? 'text-green-400' :
data.pkg.status === 'partial_built' ? 'text-yellow-400' :
data.pkg.status === 'failed' ? 'text-red-400' :
'text-gray-400'}">
{data.pkg.status === 'partial_built' ? 'partial' : data.pkg.status}
</div>
</div>
<div class="bg-[var(--surface)] rounded-xl p-4 border border-[var(--border)]">
<div class="text-sm text-[var(--text-muted)]">Updated</div>
<div class="mt-1 text-sm">{data.pkg.updated_at}</div>
</div>
</div>
<h2 class="text-lg font-semibold mb-3">Build History</h2>
<div class="space-y-2">
{#each data.pkgBuilds as build}
<a href="/builds/{build.id}"
class="block bg-[var(--surface)] rounded-lg p-3 border border-[var(--border)] hover:border-[var(--primary)] transition">
<div class="flex justify-between items-center">
<span class="text-sm">Build #{build.id}</span>
<span class="text-xs px-2 py-0.5 rounded
{build.status === 'success' ? 'bg-green-900 text-green-300' :
build.status === 'failed' ? 'bg-red-900 text-red-300' :
build.status === 'running' ? 'bg-blue-900 text-blue-300' :
'bg-yellow-900 text-yellow-300'}">
{build.status}
</span>
</div>
<div class="text-xs text-[var(--text-muted)] mt-1">
{build.target} · {build.trigger} · {build.atombuild ? '--atombuild' : ''} · {new Date(build.created_at).toLocaleString()}
</div>
{#if build.error_message}
<div class="text-xs text-red-400 mt-1 truncate">{build.error_message}</div>
{/if}
</a>
{/each}
{#if data.pkgBuilds.length === 0}
<p class="text-[var(--text-muted)] text-sm">No builds for this package</p>
{/if}
</div>
</div>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import { onMount } from 'svelte';
let username = $state('');
let password = $state('');
let output = $state('');
let copied = $state(false);
async function generate() {
if (!username || !password) return;
const res = await fetch('/api/hash-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (res.ok) {
output = `[users]\n${username} = "${data.hash}"`;
copied = false;
}
}
async function copyToClipboard() {
await navigator.clipboard.writeText(output);
copied = true;
setTimeout(() => copied = false, 2000);
}
</script>
<div class="max-w-2xl mx-auto">
<div class="mb-6">
<h1 class="text-2xl font-bold">Add User</h1>
<p class="text-[var(--text-muted)] mt-1">Generate a config snippet to add a new user account.</p>
</div>
<div class="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<div>
<label class="block text-sm text-[var(--text-muted)] mb-1">Username</label>
<input
type="text"
bind:value={username}
placeholder="e.g. alice"
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
<div>
<label class="block text-sm text-[var(--text-muted)] mb-1">Password</label>
<input
type="password"
bind:value={password}
class="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
<button
onclick={generate}
disabled={!username || !password}
class="px-4 py-2 bg-[var(--primary)] text-white rounded-lg text-sm hover:bg-[var(--primary-hover)] disabled:opacity-50"
>
Generate Config Snippet
</button>
{#if output}
<div class="mt-4">
<div class="flex justify-between items-center mb-1">
<label class="text-sm text-[var(--text-muted)]">Add to config.toml:</label>
<button
onclick={copyToClipboard}
class="text-xs text-[var(--primary)] hover:text-[var(--primary-hover)]"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<pre class="bg-[var(--bg)] border border-[var(--border)] rounded-lg p-4 text-sm font-mono overflow-x-auto whitespace-pre">{output}</pre>
</div>
<div class="mt-4 text-sm text-[var(--text-muted)] space-y-1">
<p>1. Open <code class="bg-[var(--bg)] px-1 rounded">config.toml</code> in your editor</p>
<p>2. If <code class="bg-[var(--bg)] px-1 rounded">[users]</code> section doesn't exist, paste the entire snippet at the end of the file</p>
<p>3. If <code class="bg-[var(--bg)] px-1 rounded">[users]</code> already exists, add only the <code class="bg-[var(--bg)] px-1 rounded">username = "hash"</code> line under it</p>
<p>4. Restart the build server</p>
</div>
{/if}
</div>
<p class="text-xs text-[var(--text-muted)] mt-6">
<a href="/login" class="text-[var(--primary)] hover:text-[var(--primary-hover)]">Back to login</a>
</p>
</div>
View File
+12
View File
@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({ out: 'build' })
},
preprocess: vitePreprocess()
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});