feat: shared Postgres with per-app schemas
One Postgres 16 instance runs in the infra stack (docker-compose).
Each app can be given its own isolated schema with a dedicated,
scoped Postgres user via the new Database card on the app detail page.
What was added:
infra/
docker-compose.yml — postgres:16-alpine service + hiy-pg-data
volume; POSTGRES_URL injected into server
.env.example — POSTGRES_PASSWORD entry
server/
Cargo.toml — sqlx postgres feature
src/db.rs — databases table (SQLite) migration
src/models.rs — Database model
src/main.rs — PgPool (lazy) added to AppState;
/api/apps/:id/database routes registered
src/routes/mod.rs — databases module
src/routes/databases.rs — GET / POST / DELETE handlers:
provision — creates schema + scoped PG user, sets search_path,
injects DATABASE_URL env var
deprovision — DROP OWNED BY + DROP ROLE + DROP SCHEMA CASCADE,
removes SQLite record
src/routes/ui.rs — app_detail queries databases table, renders
db_card based on provisioning state
templates/app_detail.html — {{db_card}} placeholder +
provisionDb / deprovisionDb JS
Apps connect via:
postgres://hiy-<app>:<pw>@postgres:5432/hiy
search_path is set on the role so no URL option is needed.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
parent
c113b098e1
commit
f4aa6972e1
10 changed files with 301 additions and 3 deletions
|
|
@ -7,3 +7,7 @@ DOMAIN_SUFFIX=yourdomain.com
|
||||||
# Dashboard login credentials (required in production).
|
# Dashboard login credentials (required in production).
|
||||||
HIY_ADMIN_USER=admin
|
HIY_ADMIN_USER=admin
|
||||||
HIY_ADMIN_PASS=changeme
|
HIY_ADMIN_PASS=changeme
|
||||||
|
|
||||||
|
# Postgres admin password — used by the shared cluster.
|
||||||
|
# App schemas get their own scoped users; this password never leaves the server.
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
|
|
|
||||||
|
|
@ -48,15 +48,31 @@ services:
|
||||||
# would fail: no user-namespace support in an unprivileged container).
|
# would fail: no user-namespace support in an unprivileged container).
|
||||||
CONTAINER_HOST: tcp://podman-proxy:2375
|
CONTAINER_HOST: tcp://podman-proxy:2375
|
||||||
RUST_LOG: hiy_server=debug,tower_http=info
|
RUST_LOG: hiy_server=debug,tower_http=info
|
||||||
|
POSTGRES_URL: postgres://hiy_admin:${POSTGRES_PASSWORD}@postgres:5432/hiy
|
||||||
depends_on:
|
depends_on:
|
||||||
caddy:
|
caddy:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
podman-proxy:
|
podman-proxy:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- hiy-net
|
- hiy-net
|
||||||
- default
|
- default
|
||||||
|
|
||||||
|
# ── Shared Postgres ───────────────────────────────────────────────────────
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: hiy
|
||||||
|
POSTGRES_USER: hiy_admin
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- hiy-pg-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- hiy-net
|
||||||
|
|
||||||
# ── Reverse proxy ─────────────────────────────────────────────────────────
|
# ── Reverse proxy ─────────────────────────────────────────────────────────
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
|
|
@ -88,3 +104,4 @@ volumes:
|
||||||
hiy-data:
|
hiy-data:
|
||||||
caddy-data:
|
caddy-data:
|
||||||
caddy-config:
|
caddy-config:
|
||||||
|
hiy-pg-data:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ path = "src/main.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "migrate", "chrono"] }
|
sqlx = { version = "0.7", features = ["sqlite", "postgres", "runtime-tokio-rustls", "migrate", "chrono"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
|
||||||
|
|
@ -101,5 +101,16 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS databases (
|
||||||
|
app_id TEXT PRIMARY KEY REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
pg_user TEXT NOT NULL,
|
||||||
|
pg_password TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ pub use db::DbPool;
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: DbPool,
|
pub db: DbPool,
|
||||||
|
/// Admin Postgres pool for schema/user provisioning. None if POSTGRES_URL is unset.
|
||||||
|
pub pg: Option<sqlx::PgPool>,
|
||||||
/// Queue of deploy IDs waiting to be processed.
|
/// Queue of deploy IDs waiting to be processed.
|
||||||
pub build_queue: Arc<Mutex<VecDeque<String>>>,
|
pub build_queue: Arc<Mutex<VecDeque<String>>>,
|
||||||
pub data_dir: String,
|
pub data_dir: String,
|
||||||
|
|
@ -119,8 +121,20 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let git_shell_path = std::env::var("HIY_GIT_SHELL")
|
let git_shell_path = std::env::var("HIY_GIT_SHELL")
|
||||||
.unwrap_or_else(|_| "/usr/local/bin/hiy-git-shell".into());
|
.unwrap_or_else(|_| "/usr/local/bin/hiy-git-shell".into());
|
||||||
|
|
||||||
|
let pg = match std::env::var("POSTGRES_URL") {
|
||||||
|
Ok(url) => match sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect_lazy(&url)
|
||||||
|
{
|
||||||
|
Ok(pool) => { tracing::info!("Postgres pool initialised (lazy)"); Some(pool) }
|
||||||
|
Err(e) => { tracing::warn!("Could not create Postgres pool: {}", e); None }
|
||||||
|
},
|
||||||
|
Err(_) => { tracing::info!("POSTGRES_URL not set — database provisioning disabled"); None }
|
||||||
|
};
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
|
pg,
|
||||||
build_queue,
|
build_queue,
|
||||||
data_dir,
|
data_dir,
|
||||||
sessions: auth::new_sessions(),
|
sessions: auth::new_sessions(),
|
||||||
|
|
@ -160,6 +174,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
// Env vars API
|
// Env vars API
|
||||||
.route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set))
|
.route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set))
|
||||||
.route("/api/apps/:id/env/:key", delete(routes::envvars::remove))
|
.route("/api/apps/:id/env/:key", delete(routes::envvars::remove))
|
||||||
|
// Database provisioning API
|
||||||
|
.route("/api/apps/:id/database", get(routes::databases::get_db)
|
||||||
|
.post(routes::databases::provision)
|
||||||
|
.delete(routes::databases::deprovision))
|
||||||
// Users API (admin only — already behind admin middleware)
|
// Users API (admin only — already behind admin middleware)
|
||||||
.route("/api/users", get(routes::users::list).post(routes::users::create))
|
.route("/api/users", get(routes::users::list).post(routes::users::create))
|
||||||
.route("/api/users/:id", put(routes::users::update).delete(routes::users::delete))
|
.route("/api/users/:id", put(routes::users::update).delete(routes::users::delete))
|
||||||
|
|
|
||||||
|
|
@ -103,3 +103,11 @@ pub struct ApiKey {
|
||||||
pub struct CreateApiKey {
|
pub struct CreateApiKey {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Database {
|
||||||
|
pub app_id: String,
|
||||||
|
pub pg_user: String,
|
||||||
|
pub pg_password: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
|
||||||
190
server/src/routes/databases.rs
Normal file
190
server/src/routes/databases.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{models::Database, AppState};
|
||||||
|
|
||||||
|
type ApiError = (StatusCode, String);
|
||||||
|
|
||||||
|
fn err(status: StatusCode, msg: impl Into<String>) -> ApiError {
|
||||||
|
(status, msg.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App IDs are slugs: lowercase alphanumeric + hyphens.
|
||||||
|
/// Validate before interpolating into SQL identifiers.
|
||||||
|
fn validate_app_id(id: &str) -> bool {
|
||||||
|
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conn_str(pg_user: &str, pg_password: &str) -> String {
|
||||||
|
format!("postgres://{}:{}@postgres:5432/hiy", pg_user, pg_password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/apps/:id/database ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn get_db(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let db = sqlx::query_as::<_, Database>(
|
||||||
|
"SELECT * FROM databases WHERE app_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
match db {
|
||||||
|
None => Err(err(StatusCode::NOT_FOUND, "No database provisioned")),
|
||||||
|
Some(d) => Ok(Json(json!({
|
||||||
|
"app_id": d.app_id,
|
||||||
|
"schema": d.app_id,
|
||||||
|
"pg_user": d.pg_user,
|
||||||
|
"conn_str": conn_str(&d.pg_user, &d.pg_password),
|
||||||
|
"created_at": d.created_at,
|
||||||
|
}))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/apps/:id/database ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn provision(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_id): Path<String>,
|
||||||
|
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
|
||||||
|
let pg = s.pg.as_ref()
|
||||||
|
.ok_or_else(|| err(StatusCode::SERVICE_UNAVAILABLE, "Postgres not configured on this server"))?;
|
||||||
|
|
||||||
|
if !validate_app_id(&app_id) {
|
||||||
|
return Err(err(StatusCode::BAD_REQUEST, "Invalid app id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify app exists.
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT id FROM apps WHERE id = ?")
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or_else(|| err(StatusCode::NOT_FOUND, "App not found"))?;
|
||||||
|
|
||||||
|
// Idempotency guard.
|
||||||
|
let already: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM databases WHERE app_id = ?")
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_one(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
if already > 0 {
|
||||||
|
return Err(err(StatusCode::CONFLICT, "Database already provisioned for this app"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random password: UUID v4 without hyphens → 32 hex chars, URL-safe.
|
||||||
|
let password = uuid::Uuid::new_v4().to_string().replace('-', "");
|
||||||
|
let pg_user = format!("hiy-{}", app_id);
|
||||||
|
let schema = app_id.as_str();
|
||||||
|
|
||||||
|
// All identifiers are double-quoted; passwords contain only [0-9a-f].
|
||||||
|
sqlx::query(&format!(r#"CREATE SCHEMA IF NOT EXISTS "{}""#, schema))
|
||||||
|
.execute(pg).await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
sqlx::query(&format!(r#"CREATE USER "{}" WITH PASSWORD '{}'"#, pg_user, password))
|
||||||
|
.execute(pg).await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
sqlx::query(&format!(
|
||||||
|
r#"GRANT USAGE, CREATE ON SCHEMA "{}" TO "{}""#, schema, pg_user,
|
||||||
|
))
|
||||||
|
.execute(pg).await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Default search_path so the app never has to specify it in the URL.
|
||||||
|
sqlx::query(&format!(
|
||||||
|
r#"ALTER ROLE "{}" SET search_path TO "{}""#, pg_user, schema,
|
||||||
|
))
|
||||||
|
.execute(pg).await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Persist credentials.
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO databases (app_id, pg_user, pg_password, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.bind(&pg_user)
|
||||||
|
.bind(&password)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Inject DATABASE_URL as an app env var (picked up on next deploy).
|
||||||
|
let url = conn_str(&pg_user, &password);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO env_vars (app_id, key, value) VALUES (?, 'DATABASE_URL', ?)
|
||||||
|
ON CONFLICT (app_id, key) DO UPDATE SET value = excluded.value",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.bind(&url)
|
||||||
|
.execute(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!("Provisioned database schema \"{}\" for app {}", schema, app_id);
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(json!({
|
||||||
|
"app_id": app_id,
|
||||||
|
"schema": schema,
|
||||||
|
"pg_user": pg_user,
|
||||||
|
"conn_str": url,
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/apps/:id/database ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn deprovision(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_id): Path<String>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
let pg = s.pg.as_ref()
|
||||||
|
.ok_or_else(|| err(StatusCode::SERVICE_UNAVAILABLE, "Postgres not configured on this server"))?;
|
||||||
|
|
||||||
|
if !validate_app_id(&app_id) {
|
||||||
|
return Err(err(StatusCode::BAD_REQUEST, "Invalid app id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = sqlx::query_as::<_, Database>(
|
||||||
|
"SELECT * FROM databases WHERE app_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or_else(|| err(StatusCode::NOT_FOUND, "No database provisioned for this app"))?;
|
||||||
|
|
||||||
|
// Drop all objects owned by the app user, then the role, then the schema.
|
||||||
|
sqlx::query(&format!(r#"DROP OWNED BY "{}""#, db.pg_user))
|
||||||
|
.execute(pg).await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
sqlx::query(&format!(r#"DROP ROLE IF EXISTS "{}""#, db.pg_user))
|
||||||
|
.execute(pg).await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
sqlx::query(&format!(r#"DROP SCHEMA IF EXISTS "{}" CASCADE"#, app_id))
|
||||||
|
.execute(pg).await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Remove from SQLite (also removes DATABASE_URL env var if desired — left to the user).
|
||||||
|
sqlx::query("DELETE FROM databases WHERE app_id = ?")
|
||||||
|
.bind(&app_id)
|
||||||
|
.execute(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!("Deprovisioned database for app {}", app_id);
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod api_keys;
|
pub mod api_keys;
|
||||||
pub mod apps;
|
pub mod apps;
|
||||||
|
pub mod databases;
|
||||||
pub mod deploys;
|
pub mod deploys;
|
||||||
pub mod envvars;
|
pub mod envvars;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use futures::future::join_all;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{App, Deploy, EnvVar},
|
models::{App, Database, Deploy, EnvVar},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -261,6 +261,41 @@ pub async fn app_detail(
|
||||||
|
|
||||||
let host = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
|
let host = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
|
||||||
|
|
||||||
|
let db_record = sqlx::query_as::<_, Database>(
|
||||||
|
"SELECT * FROM databases WHERE app_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
let db_card = match (s.pg.is_some(), db_record) {
|
||||||
|
(false, _) => r#"<div class="card"><h2>Database</h2>
|
||||||
|
<p class="muted">Postgres is not configured on this server.</p></div>"#
|
||||||
|
.to_string(),
|
||||||
|
(true, None) => r#"<div class="card"><h2>Database</h2>
|
||||||
|
<p class="muted" style="margin-bottom:16px">No database provisioned.</p>
|
||||||
|
<button class="primary" onclick="provisionDb()">Provision Database</button></div>"#
|
||||||
|
.to_string(),
|
||||||
|
(true, Some(db)) => {
|
||||||
|
let url = format!("postgres://{}:{}@postgres:5432/hiy", db.pg_user, db.pg_password);
|
||||||
|
format!(r#"<div class="card"><h2>Database</h2>
|
||||||
|
<table style="margin-bottom:16px">
|
||||||
|
<tr><td style="width:160px">Schema</td><td><code>{schema}</code></td></tr>
|
||||||
|
<tr><td>User</td><td><code>{user}</code></td></tr>
|
||||||
|
<tr><td>Connection string</td><td><code style="word-break:break-all">{url}</code></td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="muted" style="margin-bottom:12px;font-size:0.82rem">
|
||||||
|
DATABASE_URL is set as an env var and will be available on the next deploy.
|
||||||
|
</p>
|
||||||
|
<button class="danger" onclick="deprovisionDb()">Deprovision</button></div>"#,
|
||||||
|
schema = app_id,
|
||||||
|
user = db.pg_user,
|
||||||
|
url = url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let body = APP_DETAIL_TMPL
|
let body = APP_DETAIL_TMPL
|
||||||
.replace("{{name}}", &app.name)
|
.replace("{{name}}", &app.name)
|
||||||
.replace("{{repo}}", &app.repo_url)
|
.replace("{{repo}}", &app.repo_url)
|
||||||
|
|
@ -271,7 +306,8 @@ pub async fn app_detail(
|
||||||
.replace("{{secret}}", &app.webhook_secret)
|
.replace("{{secret}}", &app.webhook_secret)
|
||||||
.replace("{{deploy_rows}}", &deploy_rows)
|
.replace("{{deploy_rows}}", &deploy_rows)
|
||||||
.replace("{{env_rows}}", &env_rows)
|
.replace("{{env_rows}}", &env_rows)
|
||||||
.replace("{{c_badge}}", &container_badge(&container_state));
|
.replace("{{c_badge}}", &container_badge(&container_state))
|
||||||
|
.replace("{{db_card}}", &db_card);
|
||||||
|
|
||||||
Html(page(&app.name, &body)).into_response()
|
Html(page(&app.name, &body)).into_response()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{db_card}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Environment Variables</h2>
|
<h2>Environment Variables</h2>
|
||||||
<div class="row" style="margin-bottom:16px">
|
<div class="row" style="margin-bottom:16px">
|
||||||
|
|
@ -132,6 +134,17 @@ async function removeEnv(key) {
|
||||||
await fetch('/api/apps/' + APP_ID + '/env/' + key, {method:'DELETE'});
|
await fetch('/api/apps/' + APP_ID + '/env/' + key, {method:'DELETE'});
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
async function provisionDb() {
|
||||||
|
const r = await fetch('/api/apps/' + APP_ID + '/database', {method: 'POST'});
|
||||||
|
if (r.ok) window.location.reload();
|
||||||
|
else alert('Error: ' + await r.text());
|
||||||
|
}
|
||||||
|
async function deprovisionDb() {
|
||||||
|
if (!confirm('Drop the database and all its data for ' + APP_ID + '?\nThis cannot be undone.')) return;
|
||||||
|
const r = await fetch('/api/apps/' + APP_ID + '/database', {method: 'DELETE'});
|
||||||
|
if (r.ok) window.location.reload();
|
||||||
|
else alert('Error: ' + await r.text());
|
||||||
|
}
|
||||||
async function stopApp() {
|
async function stopApp() {
|
||||||
if (!confirm('Stop ' + APP_ID + '?')) return;
|
if (!confirm('Stop ' + APP_ID + '?')) return;
|
||||||
const r = await fetch('/api/apps/' + APP_ID + '/stop', {method:'POST'});
|
const r = await fetch('/api/apps/' + APP_ID + '/stop', {method:'POST'});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue