Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0df4ee4143 |
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user