1 Commits

Author SHA1 Message Date
mike 0df4ee4143 UI improvement (add device to)
build / build-linux-amd64 (push) Successful in 1m55s
2026-05-25 00:58:22 +02:00
4 changed files with 393 additions and 1 deletions
+70
View File
@@ -1004,6 +1004,76 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
"Rulează comandă…", "Rulează comandă…",
"Ejecutar comando…", "Ejecutar comando…",
), ),
"devices.add_to_group" => (
"Add to device group",
"Zur Gerätegruppe hinzufügen",
"Ajouter au groupe d'appareils",
"Adaugă la grupul de dispozitive",
"Añadir al grupo de dispositivos",
),
"devices.add_to_address_book" => (
"Add to address book",
"Zum Adressbuch hinzufügen",
"Ajouter au carnet d'adresses",
"Adaugă în agendă",
"Añadir a la libreta de direcciones",
),
"devices.add_to_strategy" => (
"Add to strategy",
"Zur Strategie hinzufügen",
"Ajouter à la stratégie",
"Adaugă la strategie",
"Añadir a la estrategia",
),
"devices.no_groups_available" => (
"No device groups yet — create one in Groups.",
"Noch keine Gerätegruppen — eine unter Gruppen anlegen.",
"Aucun groupe d'appareils — créez-en un dans Groupes.",
"Niciun grup de dispozitive — creați unul în Grupuri.",
"Aún no hay grupos de dispositivos — cree uno en Grupos.",
),
"devices.no_address_books_available" => (
"No shared address books — create one in Address books.",
"Keine geteilten Adressbücher — eines unter Adressbücher anlegen.",
"Aucun carnet d'adresses partagé — créez-en un dans Carnets d'adresses.",
"Nicio agendă partajată — creați una în Agende.",
"No hay libretas de direcciones compartidas — cree una en Libretas de direcciones.",
),
"devices.no_strategies_available" => (
"No strategies yet — create one in Strategies.",
"Noch keine Strategien — eine unter Strategien anlegen.",
"Aucune stratégie — créez-en une dans Stratégies.",
"Nicio strategie — creați una în Strategii.",
"Aún no hay estrategias — cree una en Estrategias.",
),
"devices.added_to_group" => (
"Added {0} to group {1}.",
"{0} zur Gruppe {1} hinzugefügt.",
"{0} ajouté au groupe {1}.",
"{0} adăugat la grupul {1}.",
"{0} añadido al grupo {1}.",
),
"devices.added_to_address_book" => (
"Added {0} to address book {1}.",
"{0} zum Adressbuch {1} hinzugefügt.",
"{0} ajouté au carnet d'adresses {1}.",
"{0} adăugat în agenda {1}.",
"{0} añadido a la libreta de direcciones {1}.",
),
"devices.already_in_address_book" => (
"{0} is already in address book {1}.",
"{0} ist bereits im Adressbuch {1}.",
"{0} est déjà dans le carnet d'adresses {1}.",
"{0} este deja în agenda {1}.",
"{0} ya está en la libreta de direcciones {1}.",
),
"devices.added_to_strategy" => (
"Assigned {0} to strategy {1}.",
"{0} der Strategie {1} zugewiesen.",
"{0} affecté à la stratégie {1}.",
"{0} atribuit strategiei {1}.",
"{0} asignado a la estrategia {1}.",
),
"exec.heading" => ( "exec.heading" => (
"Remote PowerShell", "Remote PowerShell",
"Remote-PowerShell", "Remote-PowerShell",
+12
View File
@@ -138,6 +138,18 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/devices/:peer_id/toggle-managed", "/admin/pages/devices/:peer_id/toggle-managed",
post(pages::devices::toggle_managed), post(pages::devices::toggle_managed),
) )
.route(
"/admin/pages/devices/:peer_id/add-to-group",
post(pages::devices::add_to_group),
)
.route(
"/admin/pages/devices/:peer_id/add-to-address-book",
post(pages::devices::add_to_address_book),
)
.route(
"/admin/pages/devices/:peer_id/add-to-strategy",
post(pages::devices::add_to_strategy),
)
.route( .route(
"/admin/pages/devices/:peer_id/exec", "/admin/pages/devices/:peer_id/exec",
get(pages::exec::index).post(pages::exec::dispatch), get(pages::exec::index).post(pages::exec::dispatch),
+296 -1
View File
@@ -364,6 +364,153 @@ pub async fn toggle_managed(
.await .await
} }
// ---------- "Add to …" POST handlers (per-device action menu) ----------
#[derive(Debug, Deserialize)]
pub struct AddToGroupForm {
pub group_id: i64,
}
pub async fn add_to_group(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
Query(pg): Query<DevicesListParams>,
Form(form): Form<AddToGroupForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cols = load_device_columns(&state, admin.user_id).await;
let page_size = load_device_page_size(&state, admin.user_id).await;
let q = pg.query();
let page = pg.page_or_first();
// Look up the group name once so the notice can use it. If the row was
// just deleted by another admin we still proceed with the (now no-op)
// INSERT OR IGNORE and surface a generic notice.
let group_name = state
.db
.device_groups_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.into_iter()
.find(|g| g.id == form.group_id)
.map(|g| g.name)
.unwrap_or_else(|| form.group_id.to_string());
state
.db
.device_group_add_peer(form.group_id, &peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
lang,
cols,
q,
page,
page_size,
"ok",
&tf2(lang, "devices.added_to_group", &peer_id, &group_name),
)
.await
}
#[derive(Debug, Deserialize)]
pub struct AddToAddressBookForm {
pub guid: String,
}
pub async fn add_to_address_book(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
Query(pg): Query<DevicesListParams>,
Form(form): Form<AddToAddressBookForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cols = load_device_columns(&state, admin.user_id).await;
let page_size = load_device_page_size(&state, admin.user_id).await;
let q = pg.query();
let page = pg.page_or_first();
let book_label = state
.db
.ab_list_all_with_owner()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.into_iter()
.find(|b| b.guid == form.guid)
.map(|b| {
if b.owner_username.is_empty() {
b.name
} else {
format!("{} ({})", b.name, b.owner_username)
}
})
.unwrap_or_else(|| form.guid.clone());
let inserted = state
.db
.ab_peer_add_admin(&form.guid, &peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (kind, msg) = if inserted {
("ok", tf2(lang, "devices.added_to_address_book", &peer_id, &book_label))
} else {
(
"ok",
tf2(lang, "devices.already_in_address_book", &peer_id, &book_label),
)
};
notice_then_table(&state, lang, cols, q, page, page_size, kind, &msg).await
}
#[derive(Debug, Deserialize)]
pub struct AddToStrategyForm {
pub strategy_id: i64,
}
pub async fn add_to_strategy(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
Query(pg): Query<DevicesListParams>,
Form(form): Form<AddToStrategyForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cols = load_device_columns(&state, admin.user_id).await;
let page_size = load_device_page_size(&state, admin.user_id).await;
let q = pg.query();
let page = pg.page_or_first();
let strategy_name = state
.db
.strategies_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.into_iter()
.find(|s| s.id == form.strategy_id)
.map(|s| s.name)
.unwrap_or_else(|| form.strategy_id.to_string());
// strategy_assign_peer_replace replaces any prior peer-scoped assignment,
// so this naturally handles "switch this device to a different strategy"
// without needing a separate "unassign first" step in the UI.
state
.db
.strategy_assign_peer_replace(form.strategy_id, &peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
lang,
cols,
q,
page,
page_size,
"ok",
&tf2(lang, "devices.added_to_strategy", &peer_id, &strategy_name),
)
.await
}
// ---------- column visibility / page size POST handlers ---------- // ---------- column visibility / page size POST handlers ----------
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -560,6 +707,29 @@ async fn render_table(
.devices_list_filtered(q, offset, page_size) .devices_list_filtered(q, offset, page_size)
.await .await
.map_err(|e| ApiError::Internal(e.to_string()))?; .map_err(|e| ApiError::Internal(e.to_string()))?;
// Per-row "Add to …" pickers need the catalog of assignment targets.
// Loaded once per render so the row loop doesn't fan out into 3*N queries.
// Address books are filtered to shared (kind=1) — personal books belong
// to a single user and are synced from the client, so adding from the
// admin dashboard would race their local edits.
let groups = state
.db
.device_groups_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let address_books: Vec<crate::database::AbOverviewRow> = state
.db
.ab_list_all_with_owner()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.into_iter()
.filter(|b| b.kind == 1)
.collect();
let strategies = state
.db
.strategies_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let mut s = String::new(); let mut s = String::new();
// `overflow-x-auto` on the inner wrapper gives the wide table a // `overflow-x-auto` on the inner wrapper gives the wide table a
@@ -642,7 +812,19 @@ async fn render_table(
} else { } else {
let always_show_pwd = unattended_pwd_always_visible(); let always_show_pwd = unattended_pwd_always_visible();
for d in &devices { for d in &devices {
render_device_row(&mut s, lang, cols, q, page, d, now, always_show_pwd); render_device_row(
&mut s,
lang,
cols,
q,
page,
d,
now,
always_show_pwd,
&groups,
&address_books,
&strategies,
);
} }
} }
s.push_str("</tbody>\n</table></div>"); s.push_str("</tbody>\n</table></div>");
@@ -812,6 +994,9 @@ fn render_device_row(
d: &DashboardDeviceRow, d: &DashboardDeviceRow,
now: chrono::DateTime<chrono::Utc>, now: chrono::DateTime<chrono::Utc>,
always_show_pwd: bool, always_show_pwd: bool,
groups: &[crate::database::DeviceGroupRow],
address_books: &[crate::database::AbOverviewRow],
strategies: &[crate::database::StrategyRow],
) { ) {
let parsed: serde_json::Value = let parsed: serde_json::Value =
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null); serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
@@ -918,6 +1103,55 @@ fn render_device_row(
format!("&q={}", url_encode(q)) format!("&q={}", url_encode(q))
}; };
// "Add to …" picker forms — one per assignment target (device group,
// shared address book, strategy). Rendered as inline <form>+<select>+<button>
// blocks inside the action popover so the admin can dispatch the
// assignment without leaving the row. The dropdowns are populated from
// the snapshot loaded once per render_table call.
let add_to_group_form = render_add_picker(
&format!(
"/admin/pages/devices/{id}/add-to-group?page={page}{q_param}",
id = html_escape(&d.id),
page = page,
q_param = q_param,
),
t(lang, "devices.add_to_group"),
"group_id",
t(lang, "devices.no_groups_available"),
groups.iter().map(|g| (g.id.to_string(), g.name.clone())),
);
let add_to_ab_form = render_add_picker(
&format!(
"/admin/pages/devices/{id}/add-to-address-book?page={page}{q_param}",
id = html_escape(&d.id),
page = page,
q_param = q_param,
),
t(lang, "devices.add_to_address_book"),
"guid",
t(lang, "devices.no_address_books_available"),
address_books.iter().map(|b| {
let label = if b.owner_username.is_empty() {
b.name.clone()
} else {
format!("{} ({})", b.name, b.owner_username)
};
(b.guid.clone(), label)
}),
);
let add_to_strategy_form = render_add_picker(
&format!(
"/admin/pages/devices/{id}/add-to-strategy?page={page}{q_param}",
id = html_escape(&d.id),
page = page,
q_param = q_param,
),
t(lang, "devices.add_to_strategy"),
"strategy_id",
t(lang, "devices.no_strategies_available"),
strategies.iter().map(|st| (st.id.to_string(), st.name.clone())),
);
// Auth toggle: the menu entry's label flips based on current state, // Auth toggle: the menu entry's label flips based on current state,
// and only the off→on transition needs no confirm (it strengthens // and only the off→on transition needs no confirm (it strengthens
// security). on→off removes the signature requirement and reintroduces // security). on→off removes the signature requirement and reintroduces
@@ -1089,6 +1323,10 @@ fn render_device_row(
{run_command} {run_command}
</button> </button>
<hr class="border-slate-700 my-1" /> <hr class="border-slate-700 my-1" />
{add_to_group_form}
{add_to_ab_form}
{add_to_strategy_form}
<hr class="border-slate-700 my-1" />
{toggle_managed_item} {toggle_managed_item}
<hr class="border-slate-700 my-1" /> <hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded" <button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
@@ -1117,6 +1355,9 @@ fn render_device_row(
page = page, page = page,
q_param = q_param, q_param = q_param,
toggle_managed_item = toggle_managed_item, toggle_managed_item = toggle_managed_item,
add_to_group_form = add_to_group_form,
add_to_ab_form = add_to_ab_form,
add_to_strategy_form = add_to_strategy_form,
connect_web = t(lang, "devices.connect_web"), connect_web = t(lang, "devices.connect_web"),
confirm_connect = html_escape(&tf1(lang, "devices.confirm_connect", &d.id)), confirm_connect = html_escape(&tf1(lang, "devices.confirm_connect", &d.id)),
details = t(lang, "devices.details"), details = t(lang, "devices.details"),
@@ -1129,6 +1370,60 @@ fn render_device_row(
); );
} }
/// Render the inline picker form used for "Add to Group / AB / Strategy"
/// inside the per-device action popover. The form posts to `action_url`
/// with one field (`field_name`) carrying the selected option's value.
/// When the option list is empty we render a disabled hint instead so
/// the slot still appears (gives the admin a discoverable "set this up"
/// breadcrumb) but can't be submitted.
fn render_add_picker<I: IntoIterator<Item = (String, String)>>(
action_url: &str,
label: &str,
field_name: &str,
empty_hint: &str,
options: I,
) -> String {
let mut opts = String::new();
let mut any = false;
for (val, txt) in options {
any = true;
let _ = write!(
opts,
r##"<option value="{v}">{t}</option>"##,
v = html_escape(&val),
t = html_escape(&txt),
);
}
if !any {
return format!(
r##"<div class="px-1 pt-1">
<div class="text-[10px] uppercase tracking-wide text-slate-500">{label}</div>
<p class="text-[10px] text-slate-600 italic px-0.5 py-0.5">{empty}</p>
</div>"##,
label = html_escape(label),
empty = html_escape(empty_hint),
);
}
format!(
r##"<div class="px-1 pt-1">
<div class="text-[10px] uppercase tracking-wide text-slate-500">{label}</div>
<form class="flex gap-1 pt-0.5"
hx-post="{action}"
hx-target="#devices-region" hx-swap="innerHTML">
<select name="{field}" required
class="flex-1 min-w-0 bg-slate-800 border border-slate-700 rounded text-xs px-1 py-0.5 text-slate-200">
{opts}
</select>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-2 text-xs text-white">+</button>
</form>
</div>"##,
label = html_escape(label),
action = action_url,
field = field_name,
opts = opts,
)
}
// ---------- detail page ---------- // ---------- detail page ----------
/// HTMX-only endpoint returning just the devices table fragment (no /// HTMX-only endpoint returning just the devices table fragment (no
+15
View File
@@ -2018,6 +2018,21 @@ impl Database {
Ok(row.try_get("c")?) Ok(row.try_get("c")?)
} }
/// Admin-side add of a peer to an address book — inserts only the
/// minimum (`ab_guid`, `peer_id`) and silently no-ops if the row is
/// already there. Returns true when a new row was inserted, false when
/// the peer was already in the book.
pub async fn ab_peer_add_admin(&self, ab_guid: &str, peer_id: &str) -> ResultType<bool> {
let res = sqlx::query(
"INSERT OR IGNORE INTO address_book_peers(ab_guid, peer_id) VALUES(?, ?)",
)
.bind(ab_guid)
.bind(peer_id)
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(res.rows_affected() > 0)
}
pub async fn ab_list_tags(&self, ab_guid: &str) -> ResultType<Vec<AbTagRow>> { pub async fn ab_list_tags(&self, ab_guid: &str) -> ResultType<Vec<AbTagRow>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT name, color FROM address_book_tags WHERE ab_guid = ? ORDER BY name", "SELECT name, color FROM address_book_tags WHERE ab_guid = ? ORDER BY name",