crates/server: add project setup endpoint and update routes
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9087c90a3b81cfa6f148a8d0131e87796a6a6964
This commit is contained in:
parent
0908317733
commit
0782d891f1
3 changed files with 377 additions and 5 deletions
|
|
@ -23,15 +23,22 @@ use axum::extract::ConnectInfo;
|
||||||
use axum::http::{HeaderValue, Request, StatusCode};
|
use axum::http::{HeaderValue, Request, StatusCode};
|
||||||
use axum::middleware::{self, Next};
|
use axum::middleware::{self, Next};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use fc_common::config::ServerConfig;
|
use fc_common::config::ServerConfig;
|
||||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
use tower_http::limit::RequestBodyLimitLayer;
|
use tower_http::limit::RequestBodyLimitLayer;
|
||||||
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::header;
|
||||||
|
|
||||||
use crate::auth_middleware::{extract_session, require_api_key};
|
use crate::auth_middleware::{extract_session, require_api_key};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
static STYLE_CSS: &str = include_str!("../../static/style.css");
|
||||||
|
|
||||||
struct RateLimitState {
|
struct RateLimitState {
|
||||||
requests: DashMap<IpAddr, Vec<Instant>>,
|
requests: DashMap<IpAddr, Vec<Instant>>,
|
||||||
_rps: u64,
|
_rps: u64,
|
||||||
|
|
@ -57,8 +64,8 @@ async fn rate_limit_middleware(
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
let last = rl.last_cleanup.load(std::sync::atomic::Ordering::Relaxed);
|
let last = rl.last_cleanup.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
if now_secs - last > 60 {
|
if now_secs - last > 60
|
||||||
if rl
|
&& rl
|
||||||
.last_cleanup
|
.last_cleanup
|
||||||
.compare_exchange(
|
.compare_exchange(
|
||||||
last,
|
last,
|
||||||
|
|
@ -73,7 +80,6 @@ async fn rate_limit_middleware(
|
||||||
!v.is_empty()
|
!v.is_empty()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut entry = rl.requests.entry(ip).or_default();
|
let mut entry = rl.requests.entry(ip).or_default();
|
||||||
entry.retain(|t| now.duration_since(*t) < window);
|
entry.retain(|t| now.duration_since(*t) < window);
|
||||||
|
|
@ -89,6 +95,15 @@ async fn rate_limit_middleware(
|
||||||
next.run(request).await
|
next.run(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn serve_style_css() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, "text/css")
|
||||||
|
.header(header::CACHE_CONTROL, "public, max-age=3600")
|
||||||
|
.body(Body::from(STYLE_CSS))
|
||||||
|
.unwrap()
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router(state: AppState, config: &ServerConfig) -> Router {
|
pub fn router(state: AppState, config: &ServerConfig) -> Router {
|
||||||
let cors_layer = if config.cors_permissive {
|
let cors_layer = if config.cors_permissive {
|
||||||
CorsLayer::permissive()
|
CorsLayer::permissive()
|
||||||
|
|
@ -104,6 +119,8 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router {
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut app = Router::new()
|
let mut app = Router::new()
|
||||||
|
// Static assets
|
||||||
|
.route("/static/style.css", get(serve_style_css))
|
||||||
// Dashboard routes with session extraction middleware
|
// Dashboard routes with session extraction middleware
|
||||||
.merge(
|
.merge(
|
||||||
dashboard::router(state.clone()).route_layer(middleware::from_fn_with_state(
|
dashboard::router(state.clone()).route_layer(middleware::from_fn_with_state(
|
||||||
|
|
@ -137,7 +154,20 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router {
|
||||||
)
|
)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(cors_layer)
|
.layer(cors_layer)
|
||||||
.layer(RequestBodyLimitLayer::new(config.max_body_size));
|
.layer(RequestBodyLimitLayer::new(config.max_body_size))
|
||||||
|
// Security headers
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::X_FRAME_OPTIONS,
|
||||||
|
HeaderValue::from_static("DENY"),
|
||||||
|
))
|
||||||
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
|
header::REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||||
|
));
|
||||||
|
|
||||||
// Add rate limiting if configured
|
// Add rate limiting if configured
|
||||||
if let (Some(rps), Some(burst)) = (config.rate_limit_rps, config.rate_limit_burst) {
|
if let (Some(rps), Some(burst)) = (config.rate_limit_rps, config.rate_limit_burst) {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::Extensions,
|
http::Extensions,
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
|
use fc_common::nix_probe;
|
||||||
use fc_common::{
|
use fc_common::{
|
||||||
CreateJobset, CreateProject, Jobset, PaginatedResponse, PaginationParams, Project,
|
CreateJobset, CreateProject, Jobset, PaginatedResponse, PaginationParams, Project,
|
||||||
UpdateProject, Validate,
|
UpdateProject, Validate,
|
||||||
|
|
@ -142,6 +143,8 @@ async fn create_project_jobset(
|
||||||
enabled: body.enabled,
|
enabled: body.enabled,
|
||||||
flake_mode: body.flake_mode,
|
flake_mode: body.flake_mode,
|
||||||
check_interval: body.check_interval,
|
check_interval: body.check_interval,
|
||||||
|
branch: None,
|
||||||
|
scheduling_shares: None,
|
||||||
};
|
};
|
||||||
input
|
input
|
||||||
.validate()
|
.validate()
|
||||||
|
|
@ -152,9 +155,99 @@ async fn create_project_jobset(
|
||||||
Ok(Json(jobset))
|
Ok(Json(jobset))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ProbeRequest {
|
||||||
|
repository_url: String,
|
||||||
|
revision: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn probe_repository(
|
||||||
|
_extensions: Extensions,
|
||||||
|
Json(body): Json<ProbeRequest>,
|
||||||
|
) -> Result<Json<nix_probe::FlakeProbeResult>, ApiError> {
|
||||||
|
let result = nix_probe::probe_flake(&body.repository_url, body.revision.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(ApiError)?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SetupJobsetInput {
|
||||||
|
name: String,
|
||||||
|
nix_expression: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SetupProjectRequest {
|
||||||
|
repository_url: String,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
jobsets: Vec<SetupJobsetInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct SetupProjectResponse {
|
||||||
|
project: Project,
|
||||||
|
jobsets: Vec<Jobset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_project(
|
||||||
|
extensions: Extensions,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<SetupProjectRequest>,
|
||||||
|
) -> Result<Json<SetupProjectResponse>, ApiError> {
|
||||||
|
RequireRoles::check(&extensions, &["create-projects"]).map_err(|s| {
|
||||||
|
ApiError(if s == axum::http::StatusCode::FORBIDDEN {
|
||||||
|
fc_common::CiError::Forbidden("Insufficient permissions".to_string())
|
||||||
|
} else {
|
||||||
|
fc_common::CiError::Unauthorized("Authentication required".to_string())
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let create_project = CreateProject {
|
||||||
|
name: body.name,
|
||||||
|
repository_url: body.repository_url,
|
||||||
|
description: body.description,
|
||||||
|
};
|
||||||
|
create_project
|
||||||
|
.validate()
|
||||||
|
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||||
|
|
||||||
|
let project = fc_common::repo::projects::create(&state.pool, create_project)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError)?;
|
||||||
|
|
||||||
|
let mut jobsets = Vec::new();
|
||||||
|
for js_input in body.jobsets {
|
||||||
|
let input = CreateJobset {
|
||||||
|
project_id: project.id,
|
||||||
|
name: js_input.name,
|
||||||
|
nix_expression: js_input.nix_expression,
|
||||||
|
enabled: Some(true),
|
||||||
|
flake_mode: Some(true),
|
||||||
|
check_interval: None,
|
||||||
|
branch: None,
|
||||||
|
scheduling_shares: None,
|
||||||
|
};
|
||||||
|
input
|
||||||
|
.validate()
|
||||||
|
.map_err(|msg| ApiError(fc_common::CiError::Validation(msg)))?;
|
||||||
|
let jobset = fc_common::repo::jobsets::create(&state.pool, input)
|
||||||
|
.await
|
||||||
|
.map_err(ApiError)?;
|
||||||
|
jobsets.push(jobset);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(SetupProjectResponse { project, jobsets }))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/projects", get(list_projects).post(create_project))
|
.route("/projects", get(list_projects).post(create_project))
|
||||||
|
.route("/projects/probe", post(probe_repository))
|
||||||
|
.route("/projects/setup", post(setup_project))
|
||||||
.route(
|
.route(
|
||||||
"/projects/{id}",
|
"/projects/{id}",
|
||||||
get(get_project).put(update_project).delete(delete_project),
|
get(get_project).put(update_project).delete(delete_project),
|
||||||
|
|
|
||||||
249
crates/server/templates/project_setup.html
Normal file
249
crates/server/templates/project_setup.html
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New Project - FC CI{% endblock %}
|
||||||
|
{% block auth %}
|
||||||
|
{% if !auth_name.is_empty() %}
|
||||||
|
<span class="auth-user">{{ auth_name }}</span>
|
||||||
|
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
|
||||||
|
{% else %}
|
||||||
|
<a href="/login">Login</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<nav class="breadcrumbs">
|
||||||
|
<a href="/">Home</a> <span class="sep">/</span>
|
||||||
|
<a href="/projects">Projects</a> <span class="sep">/</span>
|
||||||
|
<span class="current">New Project</span>
|
||||||
|
</nav>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div id="wizard">
|
||||||
|
<h1>New Project Setup</h1>
|
||||||
|
<!-- Step 1: Enter URL -->
|
||||||
|
<div id="step-1" class="wizard-step">
|
||||||
|
<h2>Step 1: Repository URL</h2>
|
||||||
|
<div class="form-card form-card-wide">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="probe-url">Flake repository URL</label>
|
||||||
|
<input type="url" id="probe-url" placeholder="https://github.com/user/repo" required>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="probe-btn" onclick="probeRepo()">Probe Repository</button>
|
||||||
|
<div id="probe-status" class="form-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Review outputs -->
|
||||||
|
<div id="step-2" class="wizard-step" hidden>
|
||||||
|
<h2>Step 2: Select Jobsets</h2>
|
||||||
|
<p class="wizard-hint">Select which outputs to build. You can customise the Nix expression for each.</p>
|
||||||
|
<div id="outputs-list"></div>
|
||||||
|
<div class="wizard-actions">
|
||||||
|
<button class="btn" onclick="goToStep(3)">Continue</button>
|
||||||
|
<button class="btn btn-outline" onclick="goToStep(1)">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Name & describe -->
|
||||||
|
<div id="step-3" class="wizard-step" hidden>
|
||||||
|
<h2>Step 3: Project Details</h2>
|
||||||
|
<div class="form-card form-card-wide">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="project-name">Project Name</label>
|
||||||
|
<input type="text" id="project-name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="project-desc">Description</label>
|
||||||
|
<textarea id="project-desc"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-actions">
|
||||||
|
<button class="btn" onclick="goToStep(4)">Review</button>
|
||||||
|
<button class="btn btn-outline" onclick="goToStep(2)">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Confirm -->
|
||||||
|
<div id="step-4" class="wizard-step" hidden>
|
||||||
|
<h2>Step 4: Confirm & Create</h2>
|
||||||
|
<div class="form-card form-card-wide">
|
||||||
|
<dl class="detail-grid" id="summary-grid"></dl>
|
||||||
|
<h3>Selected Jobsets</h3>
|
||||||
|
<div id="summary-jobsets"></div>
|
||||||
|
<div class="wizard-actions">
|
||||||
|
<button class="btn" id="create-btn" onclick="createProject()">Create Project</button>
|
||||||
|
<button class="btn btn-outline" onclick="goToStep(3)">Back</button>
|
||||||
|
</div>
|
||||||
|
<div id="create-status" class="form-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let probeResult = null;
|
||||||
|
|
||||||
|
function showSpinner(el, msg) {
|
||||||
|
el.innerHTML = '<span class="spinner"></span><span class="text-muted">' + escapeHtml(msg) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToStep(n) {
|
||||||
|
document.querySelectorAll('.wizard-step').forEach(s => s.hidden = true);
|
||||||
|
document.getElementById('step-' + n).hidden = false;
|
||||||
|
if (n === 4) buildSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeRepo() {
|
||||||
|
const url = document.getElementById('probe-url').value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
const status = document.getElementById('probe-status');
|
||||||
|
const btn = document.getElementById('probe-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
showSpinner(status, 'Probing repository\u2026 This may take a moment.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/v1/projects/probe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ repository_url: url }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||||
|
throw new Error(err.error || err.message || 'Probe failed');
|
||||||
|
}
|
||||||
|
probeResult = await resp.json();
|
||||||
|
|
||||||
|
if (!probeResult.is_flake) {
|
||||||
|
showError(status, probeResult.error || 'Not a flake repository');
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderOutputs();
|
||||||
|
|
||||||
|
// Pre-fill name from URL
|
||||||
|
const parts = url.replace(/\.git$/, '').split('/');
|
||||||
|
document.getElementById('project-name').value = parts[parts.length - 1] || '';
|
||||||
|
if (probeResult.metadata && probeResult.metadata.description) {
|
||||||
|
document.getElementById('project-desc').value = probeResult.metadata.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
goToStep(2);
|
||||||
|
} catch (e) {
|
||||||
|
showError(status, e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutputs() {
|
||||||
|
const list = document.getElementById('outputs-list');
|
||||||
|
if (!probeResult || !probeResult.suggested_jobsets.length) {
|
||||||
|
list.innerHTML = '<div class="empty">No buildable outputs detected.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="table-wrap"><table><thead><tr><th></th><th>Name</th><th>Expression</th><th>Description</th><th>Priority</th></tr></thead><tbody>';
|
||||||
|
probeResult.suggested_jobsets.forEach((js, i) => {
|
||||||
|
const checked = js.priority >= 6 ? 'checked' : '';
|
||||||
|
html += '<tr>'
|
||||||
|
+ '<td><input type="checkbox" class="js-check" data-idx="' + i + '" ' + checked + '></td>'
|
||||||
|
+ '<td>' + escapeHtml(js.name) + '</td>'
|
||||||
|
+ '<td><input type="text" class="js-expr" data-idx="' + i + '" value="' + escapeHtml(js.nix_expression) + '" class="inline-input"></td>'
|
||||||
|
+ '<td class="text-muted text-sm">' + escapeHtml(js.description) + '</td>'
|
||||||
|
+ '<td class="text-center">' + js.priority + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
|
||||||
|
if (probeResult.outputs.length) {
|
||||||
|
html += '<details class="outputs-detail"><summary>All detected outputs (' + probeResult.outputs.length + ')</summary><ul class="outputs-list">';
|
||||||
|
probeResult.outputs.forEach(o => {
|
||||||
|
html += '<li><strong>' + escapeHtml(o.path) + '</strong> (' + escapeHtml(o.output_type) + ')';
|
||||||
|
if (o.systems.length) html += ' \u2014 ' + o.systems.map(escapeHtml).join(', ');
|
||||||
|
html += '</li>';
|
||||||
|
});
|
||||||
|
html += '</ul></details>';
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedJobsets() {
|
||||||
|
const jobsets = [];
|
||||||
|
document.querySelectorAll('.js-check:checked').forEach(cb => {
|
||||||
|
const idx = parseInt(cb.dataset.idx);
|
||||||
|
const js = probeResult.suggested_jobsets[idx];
|
||||||
|
const exprInput = document.querySelector('.js-expr[data-idx="' + idx + '"]');
|
||||||
|
jobsets.push({
|
||||||
|
name: js.name,
|
||||||
|
nix_expression: exprInput ? exprInput.value : js.nix_expression,
|
||||||
|
description: js.description,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return jobsets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary() {
|
||||||
|
const name = document.getElementById('project-name').value;
|
||||||
|
const desc = document.getElementById('project-desc').value;
|
||||||
|
const url = document.getElementById('probe-url').value;
|
||||||
|
const grid = document.getElementById('summary-grid');
|
||||||
|
grid.innerHTML = '<dt>Name</dt><dd>' + escapeHtml(name) + '</dd>'
|
||||||
|
+ '<dt>Repository</dt><dd>' + escapeHtml(url) + '</dd>'
|
||||||
|
+ '<dt>Description</dt><dd>' + escapeHtml(desc || '(none)') + '</dd>';
|
||||||
|
|
||||||
|
const jobsets = getSelectedJobsets();
|
||||||
|
const jsList = document.getElementById('summary-jobsets');
|
||||||
|
if (!jobsets.length) {
|
||||||
|
jsList.innerHTML = '<div class="empty">No jobsets selected.</div>';
|
||||||
|
} else {
|
||||||
|
let html = '<div class="table-wrap"><table><thead><tr><th>Name</th><th>Expression</th></tr></thead><tbody>';
|
||||||
|
jobsets.forEach(js => {
|
||||||
|
html += '<tr><td>' + escapeHtml(js.name) + '</td><td><code>' + escapeHtml(js.nix_expression) + '</code></td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
jsList.innerHTML = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject() {
|
||||||
|
const status = document.getElementById('create-status');
|
||||||
|
const btn = document.getElementById('create-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
showSpinner(status, 'Creating project\u2026');
|
||||||
|
|
||||||
|
const name = document.getElementById('project-name').value.trim();
|
||||||
|
const desc = document.getElementById('project-desc').value.trim();
|
||||||
|
const url = document.getElementById('probe-url').value.trim();
|
||||||
|
const jobsets = getSelectedJobsets();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showError(status, 'Project name is required');
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/v1/projects/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
repository_url: url,
|
||||||
|
name: name,
|
||||||
|
description: desc || null,
|
||||||
|
jobsets: jobsets,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||||
|
throw new Error(err.error || err.message || 'Failed to create project');
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
status.innerHTML = '<div class="flash-message flash-success">Project created successfully!</div>';
|
||||||
|
setTimeout(() => { window.location.href = '/project/' + data.project.id; }, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
showError(status, e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue