use actix_web::Result as AwResult;
use actix_web::{HttpRequest, delete, get, post, web};
use maud::html;
use serde::Deserialize;
use crate::ConnectionStore;
/// Render the full connections list (used both as a full page and as HTMX partial).
fn connections_list(store: &ConnectionStore) -> maud::Markup {
let connections = store.all_meta();
let active_id = store.active_id();
html! {
div id="connections-list" class="w-100" {
@if connections.is_empty() {
p class="white-60 f6 i tc pv4" { "No connections saved yet. Add one below." }
} @else {
@for meta in &connections {
@let is_active = active_id.as_deref() == Some(&meta.id);
@let has_password = store.has_password(&meta.id);
div class={"flex flex-column pa3 br2 mb2 ba "
@if is_active { "b--orange bg-orange-10" } @else { "b--white-20 bg-near-black" }}
id={"conn-" (meta.id)}
{
div class="flex items-center justify-between" {
div class="flex-auto" {
div class="flex items-center mb1" {
span class="f5 fw6 white-90 mr2" { (meta.name) }
@if is_active {
span class="f7 bg-orange white br2 ph2 pv1 fw6 mr2" { "ACTIVE" }
}
@if !has_password {
span class="f7 bg-dark-red white br2 ph2 pv1 fw6" { "NEEDS PASSWORD" }
}
}
span class="f6 white-50 code" {
(meta.user) "@" (meta.host) ":" (meta.port)
}
}
div class="flex items-center" {
@if !is_active {
@if has_password {
button
class="bn bg-transparent white-70 hover-white pointer f6 underline mr3"
hx-post={"/connections/" (meta.id) "/activate"}
hx-target="#connections-list"
hx-swap="outerHTML"
{ "Set active" }
}
}
button
class="bn bg-transparent white-50 hover-white pointer f6"
title="Delete connection"
hx-delete={"/connections/" (meta.id)}
hx-target={"#conn-" (meta.id)}
hx-swap="outerHTML"
hx-confirm="Delete this connection?"
{ "✕" }
}
}
// Password input section (shown when no password in memory)
@if !has_password {
div class="mt3 pt3 bt b--white-10" {
form
hx-post={"/connections/" (meta.id) "/password"}
hx-target="#connections-list"
hx-swap="outerHTML"
class="flex items-center"
{
input
type="password"
name="password"
placeholder="Enter password..."
required
class="input-reset ba b--white-30 pa2 br2 f6 bg-black white flex-auto mr2";
button
type="submit"
class="bn bg-orange white br2 ph3 pv2 pointer hover-bg-dark-orange f6 fw6"
{ "Set Password" }
}
p class="f7 white-50 mt2 mb0" {
"Passwords are stored in memory only and will be lost when the server restarts."
}
}
}
}
}
}
}
}
}
#[get("/connections")]
pub async fn connections_page(
req: HttpRequest,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
let content = html! {
div class="w-100 flex flex-column" style="height: 100%;" {
div class="ph3 mt3" {
h1 class="f3 fw6 white-90 mb1 lh-title" { "Database Connections" }
p class="f6 white-50 mt0 mb4" {
"Manage your ClickHouse connections. The active connection is used for all queries."
}
// Connection list
(connections_list(&store))
// Add new connection form
div class="mt4 bg-near-black ba b--white-20 br2 pa3" {
h2 class="f5 fw6 white-90 mt0 mb3" { "Add connection" }
form
hx-post="/connections"
hx-target="#connections-list"
hx-swap="outerHTML"
hx-on--after-request="this.reset()"
class="flex flex-column" {
div class="flex flex-wrap nl2 nr2 mb3" {
div class="pa2 w-50-ns w-100" {
label class="db f6 fw6 white-70 mb1" for="conn-name" { "Name" }
input
id="conn-name"
name="name"
type="text"
placeholder="My ClickHouse"
required
class="input-reset ba b--white-30 pa2 w-100 br2 f6 bg-black white";
}
div class="pa2 w-50-ns w-100" {
label class="db f6 fw6 white-70 mb1" for="conn-host" { "Host" }
input
id="conn-host"
name="host"
type="text"
placeholder="hostname only, e.g. localhost"
value="localhost"
required
class="input-reset ba b--white-30 pa2 w-100 br2 f6 bg-black white";
span class="f7 white-40 mt1 db" { "Hostname only — no http:// or trailing slash. Use port 8443 for TLS." }
}
div class="pa2 w-25-ns w-50" {
label class="db f6 fw6 white-70 mb1" for="conn-port" { "Port" }
input
id="conn-port"
name="port"
type="number"
placeholder="8123"
value="8123"
min="1"
max="65535"
required
class="input-reset ba b--white-30 pa2 w-100 br2 f6 bg-black white";
span class="f7 white-40 mt1 db" { "8123 = HTTP · 8443 = HTTPS/TLS" }
}
div class="pa2 w-25-ns w-50" {
label class="db f6 fw6 white-70 mb1" for="conn-user" { "User" }
input
id="conn-user"
name="user"
type="text"
placeholder="default"
value="default"
required
class="input-reset ba b--white-30 pa2 w-100 br2 f6 bg-black white";
}
div class="pa2 w-50-ns w-100" {
label class="db f6 fw6 white-70 mb1" for="conn-password" { "Password" }
input
id="conn-password"
name="password"
type="password"
placeholder="(leave blank if none)"
class="input-reset ba b--white-30 pa2 w-100 br2 f6 bg-black white";
}
}
button
type="submit"
class="self-start bn bg-orange white br2 ph3 pv2 pointer hover-bg-dark-orange f6 fw6" {
"Add connection"
}
}
}
}
}
};
if req.headers().get("HX-Request").is_some() {
Ok(content)
} else {
Ok(super::render_layout(&content))
}
}
#[derive(Deserialize)]
pub struct NewConnectionForm {
name: String,
host: String,
port: u16,
user: String,
#[serde(default)]
password: String,
}
#[post("/connections")]
pub async fn add_connection(
form: web::Form<NewConnectionForm>,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
store.add(
form.name.clone(),
form.host.clone(),
form.port,
form.user.clone(),
form.password.clone(),
);
Ok(connections_list(&store))
}
#[delete("/connections/{id}")]
pub async fn delete_connection(
path: web::Path<String>,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
let id = path.into_inner();
store.remove(&id);
// Return empty markup — HTMX swaps the deleted row's outerHTML with nothing
Ok(html! {})
}
#[post("/connections/{id}/activate")]
pub async fn set_active_connection(
path: web::Path<String>,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
let id = path.into_inner();
store.set_active(&id);
Ok(connections_list(&store))
}
#[derive(Deserialize)]
pub struct PasswordForm {
password: String,
}
#[post("/connections/{id}/password")]
pub async fn set_connection_password(
path: web::Path<String>,
form: web::Form<PasswordForm>,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
let id = path.into_inner();
store.set_password(&id, form.password.clone());
log::info!("Password updated for connection {}", id);
Ok(connections_list(&store))
}