feat: added basic beacon instance management UI
This commit is contained in:
parent
7778e9b454
commit
a57a95a98a
@ -11,11 +11,12 @@ pub mod error;
|
||||
|
||||
mod router;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum BeaconEvent {
|
||||
NewBeacon(String),
|
||||
Checkin(String),
|
||||
BeaconUpdate(String),
|
||||
BeaconCommandFinished(String, i64)
|
||||
}
|
||||
|
||||
|
||||
@ -175,7 +175,10 @@ pub async fn issue_command(
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> impl IntoView {
|
||||
pub fn CommandForm(
|
||||
categories: Vec<Category>,
|
||||
beacon_info: Option<(String, String, sparse_actions::version::Version)>
|
||||
) -> impl IntoView {
|
||||
let command_action = Action::new(move |data: &SendWrapper<FormData>| {
|
||||
let data = data.clone();
|
||||
async move {
|
||||
@ -186,7 +189,7 @@ pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> impl
|
||||
let (current_cmd, set_current_cmd) = signal("Exec".to_owned());
|
||||
|
||||
view! {
|
||||
{(categories.is_empty() && beacon_id.is_none())
|
||||
{(categories.is_empty() && beacon_info.is_none())
|
||||
.then(|| view! {
|
||||
<span class="error">"Missing categories! Cannot assign a command to a category if there are no categories"</span>
|
||||
})}
|
||||
@ -211,7 +214,7 @@ pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> impl
|
||||
}>
|
||||
<fieldset>
|
||||
<legend>"Issue new command"</legend>
|
||||
{if let Some(bid) = beacon_id.clone() {
|
||||
{if let Some((bid, _, _)) = beacon_info.clone() {
|
||||
Either::Left(view! {
|
||||
<input name="target_beacon_id" type="hidden" value=bid />
|
||||
})
|
||||
@ -242,6 +245,13 @@ pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> impl
|
||||
>
|
||||
{sparse_actions::actions::ACTION_BUILDERS
|
||||
.iter()
|
||||
.filter(|b| beacon_info
|
||||
.as_ref()
|
||||
.map(|(_, os, vers)| {
|
||||
*vers >= b.required_version() &&
|
||||
b.required_os().map(|osv| osv == os).unwrap_or(true)
|
||||
})
|
||||
.unwrap_or(true))
|
||||
.map(|b| view! {
|
||||
<option value=b.name()>{b.name()}</option>
|
||||
})
|
||||
@ -306,7 +316,7 @@ pub fn CommandsView() -> impl IntoView {
|
||||
};
|
||||
|
||||
Either::Right(view! {
|
||||
<CommandForm categories beacon_id=None />
|
||||
<CommandForm categories beacon_info=None />
|
||||
})
|
||||
})}
|
||||
</Suspense>
|
||||
|
||||
@ -1,10 +1,346 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::{either::Either, prelude::*};
|
||||
|
||||
use leptos_router::params::Params;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use {crate::db::user, leptos::server_fn::error::NoCustomError};
|
||||
|
||||
use sparse_actions::version::Version;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum BeaconViewEvent {
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum BeaconClientMessage {
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BeaconInstance {
|
||||
beacon_id: String,
|
||||
template_id: i64,
|
||||
peer_ip: String,
|
||||
nickname: String,
|
||||
|
||||
cwd: String,
|
||||
operating_system: String,
|
||||
beacon_userent: String,
|
||||
hostname: String,
|
||||
|
||||
version: Version,
|
||||
|
||||
config_id: Option<i64>,
|
||||
|
||||
category_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[server(prefix = "/api/instance", endpoint = "get")]
|
||||
pub async fn get_instance_data(beacon_id: String) -> Result<BeaconInstance, ServerFnError> {
|
||||
let user = user::get_auth_session().await?;
|
||||
|
||||
if user.is_none() {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"You are not signed in!".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let db = crate::db::get_db()?;
|
||||
|
||||
let instance = sqlx::query!(
|
||||
r#"SELECT beacon_id, template_id, peer_ip, nickname, cwd, operating_system,
|
||||
beacon_userent, hostname, version as "version: Version", config_id FROM beacon_instance
|
||||
WHERE beacon_id = ?"#,
|
||||
beacon_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
let category_ids = sqlx::query!(
|
||||
"SELECT category_id FROM beacon_category_assignment WHERE beacon_id = ?",
|
||||
beacon_id
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| r.category_id)
|
||||
.collect();
|
||||
|
||||
Ok(BeaconInstance {
|
||||
beacon_id: instance.beacon_id,
|
||||
template_id: instance.template_id,
|
||||
peer_ip: instance.peer_ip,
|
||||
nickname: instance.nickname,
|
||||
|
||||
cwd: instance.cwd,
|
||||
operating_system: instance.operating_system,
|
||||
beacon_userent: instance.beacon_userent,
|
||||
hostname: instance.hostname,
|
||||
|
||||
version: instance.version,
|
||||
|
||||
config_id: instance.config_id,
|
||||
|
||||
category_ids,
|
||||
})
|
||||
}
|
||||
|
||||
#[server(prefix = "/api/instance", endpoint = "update")]
|
||||
pub async fn update_instance_prefs(
|
||||
beacon_id: String,
|
||||
nickname: String,
|
||||
config_id: i64,
|
||||
new_category_id: i64,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let config_id = (config_id > 0).then(|| config_id);
|
||||
let new_category_id = (new_category_id > 0).then(|| new_category_id);
|
||||
|
||||
let user = user::get_auth_session().await?;
|
||||
|
||||
if user.is_none() {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"You are not signed in!".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let db = crate::db::get_db()?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE beacon_instance SET nickname = ?, config_id = ?
|
||||
WHERE beacon_id = ?",
|
||||
nickname,
|
||||
config_id,
|
||||
beacon_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
if let Some(new_category_id) = new_category_id {
|
||||
sqlx::query!(
|
||||
"INSERT INTO beacon_category_assignment (beacon_id, category_id) VALUES (?, ?)",
|
||||
beacon_id,
|
||||
new_category_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let update_notifier = expect_context::<tokio::sync::broadcast::Sender::<sparse_handler::BeaconEvent>>();
|
||||
update_notifier.send(sparse_handler::BeaconEvent::BeaconUpdate(beacon_id))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(prefix = "/api/instance", endpoint = "remove_category")]
|
||||
pub async fn remove_category(
|
||||
beacon_id: String,
|
||||
category_id: i64
|
||||
) -> Result<(), ServerFnError> {
|
||||
let user = user::get_auth_session().await?;
|
||||
|
||||
if user.is_none() {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"You are not signed in!".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let db = crate::db::get_db()?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM beacon_category_assignment
|
||||
WHERE beacon_id = ? AND category_id = ?",
|
||||
beacon_id,
|
||||
category_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let update_notifier = expect_context::<tokio::sync::broadcast::Sender::<sparse_handler::BeaconEvent>>();
|
||||
update_notifier.send(sparse_handler::BeaconEvent::BeaconUpdate(beacon_id))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Params, PartialEq)]
|
||||
struct InstanceParams {
|
||||
id: String
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn InstancesView() -> impl IntoView {
|
||||
view! {
|
||||
<div>
|
||||
let instance_id = leptos_router::hooks::use_params::<InstanceParams>();
|
||||
|
||||
let update_instance = ServerAction::<UpdateInstancePrefs>::new();
|
||||
let remove_category = ServerAction::<RemoveCategory>::new();
|
||||
|
||||
let instance = Resource::new(
|
||||
move || (
|
||||
update_instance.version().get(),
|
||||
remove_category.version().get(),
|
||||
instance_id.get().expect("could not extract ID from URL").id
|
||||
),
|
||||
|(_, _, id)| get_instance_data(id)
|
||||
);
|
||||
|
||||
let super::BeaconResources {
|
||||
configs,
|
||||
templates,
|
||||
categories,
|
||||
..
|
||||
} = expect_context();
|
||||
|
||||
view! {
|
||||
<div class="instance">
|
||||
<Suspense fallback=|| view! {"Loading..."}>
|
||||
{move || Suspend::new(async move {
|
||||
let configs = match configs.await {
|
||||
Ok(cs) => cs,
|
||||
Err(e) => return Either::Left(view! {
|
||||
<p>{"There was an error loading configs:".to_string()}</p>
|
||||
<p>{format!("error: {}", e)}</p>
|
||||
})
|
||||
};
|
||||
|
||||
let templates = match templates.await {
|
||||
Ok(cs) => cs,
|
||||
Err(e) => return Either::Left(view! {
|
||||
<p>{"There was an error loading configs:".to_string()}</p>
|
||||
<p>{format!("error: {}", e)}</p>
|
||||
})
|
||||
};
|
||||
|
||||
let categories = match categories.await {
|
||||
Ok(cs) => cs,
|
||||
Err(e) => return Either::Left(view! {
|
||||
<p>{"There was an error loading configs:".to_string()}</p>
|
||||
<p>{format!("error: {}", e)}</p>
|
||||
})
|
||||
};
|
||||
|
||||
let instance = match instance.await {
|
||||
Ok(inst) => inst,
|
||||
Err(e) => return Either::Left(view! {
|
||||
<p>{"There was an error loading configs:".to_string()}</p>
|
||||
<p>{format!("error: {}", e)}</p>
|
||||
})
|
||||
};
|
||||
|
||||
let beacon_info = Some((
|
||||
instance.beacon_id.clone(),
|
||||
instance.operating_system.clone(),
|
||||
instance.version
|
||||
));
|
||||
|
||||
Either::Right(view! {
|
||||
<ManageInstance
|
||||
configs
|
||||
instance
|
||||
templates
|
||||
categories=categories.clone()
|
||||
update_instance
|
||||
remove_category
|
||||
/>
|
||||
|
||||
<super::commands::CommandForm
|
||||
categories
|
||||
beacon_info
|
||||
/>
|
||||
})
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ManageInstance(
|
||||
configs: Vec<super::configs::BeaconConfig>,
|
||||
templates: Vec<super::templates::BeaconTemplate>,
|
||||
categories: Vec<super::categories::Category>,
|
||||
instance: BeaconInstance,
|
||||
update_instance: ServerAction<UpdateInstancePrefs>,
|
||||
remove_category: ServerAction<RemoveCategory>
|
||||
) -> impl IntoView {
|
||||
|
||||
view! {
|
||||
<h3>"Beacon instance"</h3>
|
||||
<div><span>"Beacon ID: "</span> {instance.beacon_id.clone()}</div>
|
||||
<div><span>"Peer IP: "</span> {instance.peer_ip.clone()}</div>
|
||||
<div><span>"Current working directory: "</span> {instance.cwd.clone()}</div>
|
||||
<div><span>"Operating system: "</span> {instance.operating_system.clone()}</div>
|
||||
<div><span>"User: "</span> {instance.beacon_userent.clone()}</div>
|
||||
<div><span>"Hostname: "</span> {instance.hostname.clone()}</div>
|
||||
<div><span>"Version: "</span> {instance.version.to_string()}</div>
|
||||
{templates.iter().find(|t| t.template_id == instance.template_id).map(|t| view! {
|
||||
<div><span>"Template: "</span> {t.template_name.clone()}</div>
|
||||
})}
|
||||
<div>"Categories:"</div>
|
||||
<ul>
|
||||
{categories
|
||||
.iter()
|
||||
.filter(|cat| instance.category_ids.contains(&cat.category_id))
|
||||
.map(|cat| view! {
|
||||
<li>
|
||||
{cat.category_name.clone()}
|
||||
|
||||
<button
|
||||
on:click={
|
||||
let category_id = cat.category_id;
|
||||
let beacon_id = instance.beacon_id.clone();
|
||||
move |_| {
|
||||
remove_category.dispatch(RemoveCategory {
|
||||
beacon_id: beacon_id.clone(),
|
||||
category_id
|
||||
});
|
||||
}
|
||||
}
|
||||
class="warning"
|
||||
>
|
||||
"Remove"
|
||||
</button>
|
||||
</li>
|
||||
})
|
||||
.collect_view()}
|
||||
</ul>
|
||||
<ActionForm action=update_instance>
|
||||
<fieldset>
|
||||
<input type="hidden" name="beacon_id" value=instance.beacon_id.clone()/>
|
||||
<legend>"Beacon configuration preferences"</legend>
|
||||
<label>"Nickname"</label>
|
||||
<input name="nickname" value=instance.nickname.clone()/>
|
||||
<label>"Override configuration"</label>
|
||||
<select name="config_id">
|
||||
<option value="-1">"---"</option>
|
||||
{configs
|
||||
.iter()
|
||||
.map(|conf| view! {
|
||||
<option
|
||||
value=conf.config_id
|
||||
selected=Some(conf.config_id) == instance.config_id
|
||||
>
|
||||
{conf.config_name.clone()}
|
||||
</option>
|
||||
})
|
||||
.collect_view()}
|
||||
</select>
|
||||
<label>"Add new category"</label>
|
||||
<select name="new_category_id">
|
||||
<option value="-1">"---"</option>
|
||||
{categories
|
||||
.iter()
|
||||
.filter(|cat| !instance.category_ids.contains(&cat.category_id))
|
||||
.map(|cat| view! {
|
||||
<option value=cat.category_id>
|
||||
{cat.category_name.clone()}
|
||||
</option>
|
||||
})
|
||||
.collect_view()}
|
||||
</select>
|
||||
<div></div>
|
||||
<input type="submit" value="Submit"/>
|
||||
</fieldset>
|
||||
</ActionForm>
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,13 +104,13 @@ pub fn BeaconSidebar() -> impl IntoView {
|
||||
match m {
|
||||
SidebarEvents::BeaconList(bs) => {
|
||||
let mut bs = bs.to_vec();
|
||||
bs.sort_by_key(|b| b.last_checkin);
|
||||
bs.sort_by_key(|b| std::cmp::Reverse(b.last_checkin));
|
||||
current_beacons.set(Some(bs));
|
||||
}
|
||||
SidebarEvents::NewBeacon(b) => {
|
||||
current_beacons.update(|bso| {
|
||||
if let Some(ref mut bs) = bso {
|
||||
bs.push(b.clone())
|
||||
bs.insert(0, b.clone())
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -126,9 +126,15 @@ pub fn BeaconSidebar() -> impl IntoView {
|
||||
let Some(ref mut bs) = bs else {
|
||||
return;
|
||||
};
|
||||
if let Some(ref mut b) = bs.iter_mut().find(|b| b.beacon_id == *bid) {
|
||||
b.last_checkin = chrono::Utc::now();
|
||||
let Some(bpos) = bs.iter().position(|b| b.beacon_id == *bid) else {
|
||||
return;
|
||||
};
|
||||
bs[bpos].last_checkin = chrono::Utc::now();
|
||||
let first = bs[bpos].clone();
|
||||
for i in (1..=bpos).rev() {
|
||||
bs[i] = bs[i - 1].clone();
|
||||
}
|
||||
bs[0] = first;
|
||||
}),
|
||||
}
|
||||
});
|
||||
@ -558,7 +564,7 @@ pub fn BeaconSidebar() -> impl IntoView {
|
||||
each={
|
||||
move || (partition.beacons)()
|
||||
}
|
||||
key=|b| (b.beacon_id.clone(), b.last_checkin)
|
||||
key=|b| (b.beacon_id.clone(), b.last_checkin, b.nickname.clone(), b.category_ids.len())
|
||||
let:beacon
|
||||
>
|
||||
<div class="beacon-instance">
|
||||
@ -574,6 +580,7 @@ pub fn BeaconSidebar() -> impl IntoView {
|
||||
Either::Right(view! {
|
||||
<A href=format!("/beacons/beacon/{}", &beacon.beacon_id)>
|
||||
{nick}
|
||||
" "
|
||||
<span>"(" {beacon.beacon_id.clone()} ")"</span>
|
||||
</A>
|
||||
})
|
||||
|
||||
@ -361,6 +361,20 @@ async fn handle_listener_events(
|
||||
|
||||
use crate::beacons::sidebar::{CurrentBeaconInstance, SidebarEvents};
|
||||
|
||||
struct CheckinResult {
|
||||
beacon_id: String,
|
||||
checkin_date: chrono::DateTime<chrono::Utc>
|
||||
}
|
||||
|
||||
impl sqlx::FromRow<'_, SqliteRow> for CheckinResult {
|
||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||
Ok(CheckinResult {
|
||||
beacon_id: row.get("beacon_id"),
|
||||
checkin_date: row.get("checkin_date")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let beacons = sqlx::query!(
|
||||
r#"SELECT beacon_id, template_id, peer_ip, nickname, cwd, operating_system,
|
||||
@ -369,20 +383,6 @@ async fn handle_listener_events(
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
struct CheckinResult {
|
||||
beacon_id: String,
|
||||
checkin_date: chrono::DateTime<chrono::Utc>
|
||||
}
|
||||
|
||||
impl sqlx::FromRow<'_, SqliteRow> for CheckinResult {
|
||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||
Ok(CheckinResult {
|
||||
beacon_id: row.get("beacon_id"),
|
||||
checkin_date: row.get("checkin_date")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let last_checkin: Vec<CheckinResult> = sqlx::query_as(
|
||||
"SELECT beacon_id, MAX(checkin_date) as checkin_date FROM beacon_checkin
|
||||
GROUP BY beacon_id"
|
||||
@ -476,6 +476,49 @@ async fn handle_listener_events(
|
||||
let json = serde_json::to_string(&SidebarEvents::NewBeacon(beacon))?;
|
||||
socket.send(ws::Message::Text(json)).await?;
|
||||
}
|
||||
Ok(BeaconEvent::BeaconUpdate(bid)) => {
|
||||
let beacon = sqlx::query!(
|
||||
r#"SELECT template_id, peer_ip, nickname, cwd, operating_system, beacon_userent, hostname, config_id, version as "version: Version" FROM beacon_instance
|
||||
WHERE beacon_id = ?"#,
|
||||
bid
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let category_ids = sqlx::query!(
|
||||
"SELECT category_id FROM beacon_category_assignment
|
||||
WHERE beacon_id = ?",
|
||||
bid
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let check_in: CheckinResult = sqlx::query_as(
|
||||
r#"SELECT beacon_id, MAX(checkin_date) as checkin_date FROM beacon_checkin
|
||||
WHERE beacon_id = ?"#,
|
||||
)
|
||||
.bind(&bid)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let beacon = CurrentBeaconInstance {
|
||||
beacon_id: bid,
|
||||
template_id: beacon.template_id,
|
||||
ip: beacon.peer_ip,
|
||||
nickname: beacon.nickname,
|
||||
cwd: beacon.cwd,
|
||||
operating_system: beacon.operating_system,
|
||||
userent: beacon.beacon_userent,
|
||||
hostname: beacon.hostname,
|
||||
config_id: beacon.config_id,
|
||||
version: beacon.version,
|
||||
last_checkin: check_in.checkin_date,
|
||||
category_ids: category_ids.iter().map(|r| r.category_id).collect()
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&SidebarEvents::BeaconUpdate(beacon.beacon_id.clone(), beacon))?;
|
||||
socket.send(ws::Message::Text(json)).await?;
|
||||
}
|
||||
Ok(_) => {
|
||||
// this event isn't meant for public announcement
|
||||
}
|
||||
@ -486,6 +529,43 @@ async fn handle_listener_events(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn attach_to_beacon(
|
||||
State(state): State<AppState>,
|
||||
cookie_jar: CookieJar,
|
||||
ws: ws::WebSocketUpgrade
|
||||
) -> axum::response::Response {
|
||||
let user = match crate::db::user::get_auth_session_inner(
|
||||
state.db.clone(),
|
||||
cookie_jar
|
||||
).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not load user session: {e:?}");
|
||||
return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.is_none() {
|
||||
return axum::http::StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
ws
|
||||
.on_upgrade(move |socket: ws::WebSocket| async move {
|
||||
if let Err(e) = handle_beacon_socket(socket, state).await {
|
||||
tracing::warn!("Encountered error when handling beacon subscriber: {e}");
|
||||
};
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn handle_beacon_socket(
|
||||
mut socket: ws::WebSocket,
|
||||
state: AppState,
|
||||
) -> Result<(), crate::error::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
|
||||
pub async fn serve_web(
|
||||
management_address: SocketAddrV4,
|
||||
file_store: PathBuf,
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
div.instance {
|
||||
padding: 10px;
|
||||
overflow-y: scroll;
|
||||
|
||||
fieldset {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 400px 200px;
|
||||
grid-row-gap: 10px;
|
||||
|
||||
input, label {
|
||||
margin: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -68,6 +68,7 @@ aside.beacons {
|
||||
|
||||
.beacon-instance-id a {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user