SHA256
+23
@@ -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
@@ -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` |
|
||||
Generated
+1825
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
Generated
+3247
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
+34
@@ -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);
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user?: { username: string };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user ?? null,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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' });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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' });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 ?? '' };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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">×</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">✓</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">✗</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">×</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} · {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">×</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 };
|
||||
};
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user