mirror of
https://github.com/warp-tech/warpgate.git
synced 2024-09-20 06:46:17 +08:00
Paginate sessions list, added filtering (#161)
This commit is contained in:
parent
a3a8a60156
commit
6830c0c20d
4
Cargo.lock
generated
4
Cargo.lock
generated
|
@ -2708,9 +2708,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "russh-keys"
|
||||
version = "0.22.0-beta.2"
|
||||
version = "0.22.0-beta.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cead36ac2cc5b61c6774a47568117980d3df4ccf1e3dd504614809832c0781b"
|
||||
checksum = "2163fb2fcdc1f03f8b37dd93392779461f4181c3a109edf0ffb1bb03e6efbff4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bcrypt-pbkdf",
|
||||
|
|
|
@ -45,7 +45,7 @@ poem-openapi = { version = "^1.3.30", features = [
|
|||
"static-files",
|
||||
] }
|
||||
russh = { version = "0.34.0-beta.5", features = ["openssl"] }
|
||||
russh-keys = { version = "0.22.0-beta.2", features = ["openssl"] }
|
||||
russh-keys = { version = "0.22.0-beta.3", features = ["openssl"] }
|
||||
rust-embed = "6.3"
|
||||
time = "0.3"
|
||||
tokio = { version = "1.19", features = ["tracing", "signal", "macros"] }
|
||||
|
|
|
@ -3,6 +3,7 @@ use poem_openapi::OpenApi;
|
|||
pub mod known_hosts_detail;
|
||||
pub mod known_hosts_list;
|
||||
pub mod logs;
|
||||
mod pagination;
|
||||
pub mod recordings_detail;
|
||||
pub mod sessions_detail;
|
||||
pub mod sessions_list;
|
||||
|
|
55
warpgate-admin/src/api/pagination.rs
Normal file
55
warpgate-admin/src/api/pagination.rs
Normal file
|
@ -0,0 +1,55 @@
|
|||
use poem_openapi::types::{ParseFromJSON, ToJSON};
|
||||
use poem_openapi::Object;
|
||||
use sea_orm::{ConnectionTrait, EntityTrait, FromQueryResult, PaginatorTrait, QuerySelect, Select};
|
||||
|
||||
#[derive(Object)]
|
||||
#[oai(inline)]
|
||||
pub struct PaginatedResponse<T: ParseFromJSON + ToJSON + Send + Sync> {
|
||||
items: Vec<T>,
|
||||
offset: u64,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
pub struct PaginationParams {
|
||||
pub offset: Option<u64>,
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
impl<T: ParseFromJSON + ToJSON + Send + Sync> PaginatedResponse<T> {
|
||||
pub async fn new<E, M, C, P>(
|
||||
query: Select<E>,
|
||||
params: PaginationParams,
|
||||
db: &'_ C,
|
||||
postprocess: P,
|
||||
) -> poem::Result<PaginatedResponse<T>>
|
||||
where
|
||||
E: EntityTrait<Model = M>,
|
||||
C: ConnectionTrait,
|
||||
M: FromQueryResult + Sized + Send + Sync + 'static,
|
||||
P: FnMut(E::Model) -> T,
|
||||
{
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(100);
|
||||
|
||||
let paginator = query.clone().paginate(db, limit as usize);
|
||||
|
||||
let total = paginator
|
||||
.num_items()
|
||||
.await
|
||||
.map_err(poem::error::InternalServerError)? as u64;
|
||||
|
||||
let query = query.offset(offset).limit(limit);
|
||||
|
||||
let items = query
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(poem::error::InternalServerError)?;
|
||||
|
||||
let items = items.into_iter().map(postprocess).collect::<Vec<_>>();
|
||||
Ok(PaginatedResponse {
|
||||
items,
|
||||
offset,
|
||||
total,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,7 +1,13 @@
|
|||
use super::pagination::{PaginatedResponse, PaginationParams};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use poem::session::Session;
|
||||
use poem::web::websocket::{Message, WebSocket};
|
||||
use poem::web::Data;
|
||||
use poem::{handler, IntoResponse};
|
||||
use poem_openapi::param::Query;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::{ApiResponse, OpenApi};
|
||||
use sea_orm::{DatabaseConnection, EntityTrait, QueryOrder};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use warpgate_common::{SessionSnapshot, State};
|
||||
|
@ -11,7 +17,7 @@ pub struct Api;
|
|||
#[derive(ApiResponse)]
|
||||
enum GetSessionsResponse {
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<Vec<SessionSnapshot>>),
|
||||
Ok(Json<PaginatedResponse<SessionSnapshot>>),
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
|
@ -26,20 +32,35 @@ impl Api {
|
|||
async fn api_get_all_sessions(
|
||||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
offset: Query<Option<u64>>,
|
||||
limit: Query<Option<u64>>,
|
||||
active_only: Query<Option<bool>>,
|
||||
logged_in_only: Query<Option<bool>>,
|
||||
) -> poem::Result<GetSessionsResponse> {
|
||||
use warpgate_db_entities::Session;
|
||||
|
||||
let db = db.lock().await;
|
||||
let sessions = Session::Entity::find()
|
||||
.order_by_desc(Session::Column::Started)
|
||||
.all(&*db)
|
||||
.await
|
||||
.map_err(poem::error::InternalServerError)?;
|
||||
let sessions = sessions
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<SessionSnapshot>>();
|
||||
Ok(GetSessionsResponse::Ok(Json(sessions)))
|
||||
let mut q = Session::Entity::find().order_by_desc(Session::Column::Started);
|
||||
|
||||
if active_only.unwrap_or(false) {
|
||||
q = q.filter(Session::Column::Ended.is_null());
|
||||
}
|
||||
if logged_in_only.unwrap_or(false) {
|
||||
q = q.filter(Session::Column::Username.is_not_null());
|
||||
}
|
||||
|
||||
Ok(GetSessionsResponse::Ok(Json(
|
||||
PaginatedResponse::new(
|
||||
q,
|
||||
PaginationParams {
|
||||
limit: *limit,
|
||||
offset: *offset,
|
||||
},
|
||||
&*db,
|
||||
Into::into,
|
||||
)
|
||||
.await?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[oai(
|
||||
|
@ -50,6 +71,7 @@ impl Api {
|
|||
async fn api_close_all_sessions(
|
||||
&self,
|
||||
state: Data<&Arc<Mutex<State>>>,
|
||||
session: &Session,
|
||||
) -> poem::Result<CloseAllSessionsResponse> {
|
||||
let state = state.lock().await;
|
||||
|
||||
|
@ -58,6 +80,26 @@ impl Api {
|
|||
session.handle.close();
|
||||
}
|
||||
|
||||
session.purge();
|
||||
|
||||
Ok(CloseAllSessionsResponse::Ok)
|
||||
}
|
||||
}
|
||||
|
||||
#[handler]
|
||||
pub async fn api_get_sessions_changes_stream(
|
||||
ws: WebSocket,
|
||||
state: Data<&Arc<Mutex<State>>>,
|
||||
) -> impl IntoResponse {
|
||||
let mut receiver = state.lock().await.subscribe();
|
||||
|
||||
ws.on_upgrade(|socket| async move {
|
||||
let (mut sink, _) = socket.split();
|
||||
|
||||
while receiver.recv().await.is_ok() {
|
||||
sink.send(Message::Text("".to_string())).await?;
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ impl Api {
|
|||
.into_iter()
|
||||
.map(|k| SSHKey {
|
||||
kind: k.name().to_owned(),
|
||||
public_key_base64: k.public_key_base64().replace('\n', "").replace('\r', ""),
|
||||
public_key_base64: k.public_key_base64(),
|
||||
})
|
||||
.collect();
|
||||
Ok(GetSSHOwnKeysResponse::Ok(Json(keys)))
|
||||
|
|
|
@ -36,6 +36,10 @@ pub fn admin_api_app(services: &Services) -> impl IntoEndpoint {
|
|||
"/recordings/:id/tcpdump",
|
||||
crate::api::recordings_detail::api_get_recording_tcpdump,
|
||||
)
|
||||
.at(
|
||||
"/sessions/changes",
|
||||
crate::api::sessions_list::api_get_sessions_changes_stream,
|
||||
)
|
||||
.data(db)
|
||||
.data(config_provider)
|
||||
.data(state)
|
||||
|
|
|
@ -102,7 +102,6 @@ impl ConfigProvider for FileConfigProvider {
|
|||
let mut base64_bytes = BASE64_MIME.encode(public_key_bytes);
|
||||
base64_bytes.pop();
|
||||
base64_bytes.pop();
|
||||
let base64_bytes = base64_bytes.replace("\r\n", "");
|
||||
|
||||
let client_key = format!("{} {}", kind, base64_bytes);
|
||||
debug!(username = &user.username[..], "Client key: {}", client_key);
|
||||
|
|
|
@ -20,6 +20,6 @@ pub use config_providers::*;
|
|||
pub use data::*;
|
||||
pub use protocols::*;
|
||||
pub use services::*;
|
||||
pub use state::{SessionState, State};
|
||||
pub use state::{SessionState, SessionStateInit, State};
|
||||
pub use try_macro::*;
|
||||
pub use types::*;
|
||||
|
|
|
@ -43,7 +43,9 @@ impl WarpgateServerHandle {
|
|||
use sea_orm::ActiveValue::Set;
|
||||
|
||||
{
|
||||
self.session_state.lock().await.username = Some(username.clone())
|
||||
let mut state = self.session_state.lock().await;
|
||||
state.username = Some(username.clone());
|
||||
state.emit_change()
|
||||
}
|
||||
|
||||
let db = self.db.lock().await;
|
||||
|
@ -63,7 +65,9 @@ impl WarpgateServerHandle {
|
|||
pub async fn set_target(&self, target: &Target) -> Result<()> {
|
||||
use sea_orm::ActiveValue::Set;
|
||||
{
|
||||
self.session_state.lock().await.target = Some(target.clone());
|
||||
let mut state = self.session_state.lock().await;
|
||||
state.target = Some(target.clone());
|
||||
state.emit_change()
|
||||
}
|
||||
|
||||
let db = self.db.lock().await;
|
||||
|
|
|
@ -4,7 +4,7 @@ use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait};
|
|||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Weak};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tracing::*;
|
||||
use uuid::Uuid;
|
||||
use warpgate_db_entities::Session;
|
||||
|
@ -13,15 +13,18 @@ pub struct State {
|
|||
pub sessions: HashMap<SessionId, Arc<Mutex<SessionState>>>,
|
||||
db: Arc<Mutex<DatabaseConnection>>,
|
||||
this: Weak<Mutex<Self>>,
|
||||
change_sender: broadcast::Sender<()>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(db: &Arc<Mutex<DatabaseConnection>>) -> Arc<Mutex<Self>> {
|
||||
let sender = broadcast::channel(2).0;
|
||||
Arc::<Mutex<Self>>::new_cyclic(|me| {
|
||||
Mutex::new(Self {
|
||||
sessions: HashMap::new(),
|
||||
db: db.clone(),
|
||||
this: me.clone(),
|
||||
change_sender: sender,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -29,10 +32,16 @@ impl State {
|
|||
pub async fn register_session(
|
||||
&mut self,
|
||||
protocol: &ProtocolName,
|
||||
session: &Arc<Mutex<SessionState>>,
|
||||
state: SessionStateInit,
|
||||
) -> Result<Arc<Mutex<WarpgateServerHandle>>> {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
self.sessions.insert(id, session.clone());
|
||||
|
||||
let state = Arc::new(Mutex::new(SessionState::new(
|
||||
state,
|
||||
self.change_sender.clone(),
|
||||
)));
|
||||
|
||||
self.sessions.insert(id, state.clone());
|
||||
|
||||
{
|
||||
use sea_orm::ActiveValue::Set;
|
||||
|
@ -40,7 +49,7 @@ impl State {
|
|||
let values = Session::ActiveModel {
|
||||
id: Set(id),
|
||||
started: Set(chrono::Utc::now()),
|
||||
remote_address: Set(session
|
||||
remote_address: Set(state
|
||||
.lock()
|
||||
.await
|
||||
.remote_address
|
||||
|
@ -57,23 +66,31 @@ impl State {
|
|||
.context("Error inserting session")?;
|
||||
}
|
||||
|
||||
let _ = self.change_sender.send(());
|
||||
|
||||
match self.this.upgrade() {
|
||||
Some(this) => Ok(Arc::new(Mutex::new(WarpgateServerHandle::new(
|
||||
id,
|
||||
self.db.clone(),
|
||||
this,
|
||||
session.clone(),
|
||||
state,
|
||||
)))),
|
||||
None => anyhow::bail!("State is being detroyed"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&mut self) -> broadcast::Receiver<()> {
|
||||
self.change_sender.subscribe()
|
||||
}
|
||||
|
||||
pub async fn remove_session(&mut self, id: SessionId) {
|
||||
self.sessions.remove(&id);
|
||||
|
||||
if let Err(error) = self.mark_session_complete(id).await {
|
||||
error!(%error, %id, "Could not update session in the DB");
|
||||
}
|
||||
|
||||
let _ = self.change_sender.send(());
|
||||
}
|
||||
|
||||
async fn mark_session_complete(&mut self, id: Uuid) -> Result<()> {
|
||||
|
@ -95,15 +112,26 @@ pub struct SessionState {
|
|||
pub username: Option<String>,
|
||||
pub target: Option<Target>,
|
||||
pub handle: Box<dyn SessionHandle + Send>,
|
||||
change_sender: broadcast::Sender<()>,
|
||||
}
|
||||
|
||||
pub struct SessionStateInit {
|
||||
pub remote_address: Option<SocketAddr>,
|
||||
pub handle: Box<dyn SessionHandle + Send>,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
pub fn new(remote_address: Option<SocketAddr>, handle: Box<dyn SessionHandle + Send>) -> Self {
|
||||
fn new(init: SessionStateInit, change_sender: broadcast::Sender<()>) -> Self {
|
||||
SessionState {
|
||||
remote_address,
|
||||
remote_address: init.remote_address,
|
||||
username: None,
|
||||
target: None,
|
||||
handle,
|
||||
handle: init.handle,
|
||||
change_sender,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_change(&self) {
|
||||
let _ = self.change_sender.send(());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use http::StatusCode;
|
||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
||||
use poem::session::Session;
|
||||
use poem::web::{Data, Redirect};
|
||||
use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, Response};
|
||||
use warpgate_common::{Services, TargetOptions};
|
||||
use std::time::Duration;
|
||||
use warpgate_common::{ProtocolName, Services, TargetOptions};
|
||||
|
||||
pub const PROTOCOL_NAME: ProtocolName = "HTTP";
|
||||
static USERNAME_SESSION_KEY: &str = "username";
|
||||
static TARGET_SESSION_KEY: &str = "target_name";
|
||||
pub static SESSION_MAX_AGE: Duration = Duration::from_secs(60 * 30);
|
||||
pub static COOKIE_MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24);
|
||||
|
||||
pub trait SessionExt {
|
||||
fn has_selected_target(&self) -> bool;
|
||||
|
|
|
@ -6,11 +6,12 @@ mod logging;
|
|||
mod proxy;
|
||||
mod session;
|
||||
mod session_handle;
|
||||
use crate::common::{endpoint_admin_auth, endpoint_auth, page_auth, SESSION_MAX_AGE};
|
||||
use crate::common::{endpoint_admin_auth, endpoint_auth, page_auth, COOKIE_MAX_AGE};
|
||||
use crate::session::{SessionMiddleware, SharedSessionStorage};
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use common::page_admin_auth;
|
||||
pub use common::PROTOCOL_NAME;
|
||||
use logging::{log_request_result, span_for_request};
|
||||
use poem::endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint};
|
||||
use poem::listener::{Listener, RustlsCertificate, RustlsConfig, TcpListener};
|
||||
|
@ -26,11 +27,9 @@ use std::time::Duration;
|
|||
use tokio::sync::Mutex;
|
||||
use tracing::*;
|
||||
use warpgate_admin::admin_api_app;
|
||||
use warpgate_common::{ProtocolName, ProtocolServer, Services, Target, TargetTestError};
|
||||
use warpgate_common::{ProtocolServer, Services, Target, TargetTestError};
|
||||
use warpgate_web::Assets;
|
||||
|
||||
pub const PROTOCOL_NAME: ProtocolName = "HTTP";
|
||||
|
||||
pub struct HTTPProtocolServer {
|
||||
services: Services,
|
||||
}
|
||||
|
@ -109,7 +108,7 @@ impl ProtocolServer for HTTPProtocolServer {
|
|||
.with(ServerSession::new(
|
||||
CookieConfig::default()
|
||||
.secure(false)
|
||||
.max_age(SESSION_MAX_AGE)
|
||||
.max_age(COOKIE_MAX_AGE)
|
||||
.name("warpgate-http-session"),
|
||||
session_storage.clone(),
|
||||
))
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::common::SESSION_MAX_AGE;
|
||||
use crate::common::{PROTOCOL_NAME, SESSION_MAX_AGE};
|
||||
use crate::session_handle::{
|
||||
HttpSessionHandle, SessionHandleCommand, WarpgateServerHandleFromRequest,
|
||||
};
|
||||
|
@ -10,7 +10,8 @@ use std::collections::{BTreeMap, HashMap};
|
|||
use std::sync::{Arc, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use warpgate_common::{Services, SessionId, SessionState, WarpgateServerHandle};
|
||||
use tracing::*;
|
||||
use warpgate_common::{Services, SessionId, WarpgateServerHandle, SessionStateInit};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SharedSessionStorage(pub Arc<Mutex<Box<dyn SessionStorage>>>);
|
||||
|
@ -59,7 +60,7 @@ pub struct SessionMiddleware {
|
|||
}
|
||||
|
||||
static SESSION_ID_SESSION_KEY: &str = "session_id";
|
||||
static SESSION_ID_REQUEST_COUNTER: &str = "request_counter";
|
||||
static REQUEST_COUNTER_SESSION_KEY: &str = "request_counter";
|
||||
|
||||
impl SessionMiddleware {
|
||||
pub fn new() -> Arc<Mutex<Self>> {
|
||||
|
@ -75,14 +76,14 @@ impl SessionMiddleware {
|
|||
pub async fn process_request(&mut self, req: Request) -> poem::Result<Request> {
|
||||
let session: &Session = <_>::from_request_without_body(&req).await?;
|
||||
|
||||
let request_counter = session.get::<u64>(SESSION_ID_REQUEST_COUNTER).unwrap_or(0);
|
||||
session.set(SESSION_ID_REQUEST_COUNTER, request_counter + 1);
|
||||
let request_counter = session.get::<u64>(REQUEST_COUNTER_SESSION_KEY).unwrap_or(0);
|
||||
session.set(REQUEST_COUNTER_SESSION_KEY, request_counter + 1);
|
||||
|
||||
if let Some(session_id) = session.get::<SessionId>(SESSION_ID_SESSION_KEY) {
|
||||
self.session_timestamps.insert(session_id, Instant::now());
|
||||
} else if request_counter == 5 {
|
||||
// } else if request_counter == 5 {
|
||||
// Start logging sessions when they've got 5 requests
|
||||
self.create_handle_for(&req).await?;
|
||||
// self.create_handle_for(&req).await?;
|
||||
};
|
||||
|
||||
Ok(req)
|
||||
|
@ -104,16 +105,18 @@ impl SessionMiddleware {
|
|||
Data::<&SharedSessionStorage>::from_request_without_body(&req).await?;
|
||||
|
||||
let (session_handle, mut session_handle_rx) = HttpSessionHandle::new();
|
||||
let session_state = Arc::new(Mutex::new(SessionState::new(
|
||||
remote_address.0.as_socket_addr().cloned(),
|
||||
Box::new(session_handle),
|
||||
)));
|
||||
|
||||
let server_handle = services
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.register_session(&crate::PROTOCOL_NAME, &session_state)
|
||||
.register_session(
|
||||
&PROTOCOL_NAME,
|
||||
SessionStateInit {
|
||||
remote_address: remote_address.0.as_socket_addr().cloned(),
|
||||
handle: Box::new(session_handle),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id = server_handle.lock().await.id();
|
||||
|
@ -133,7 +136,10 @@ impl SessionMiddleware {
|
|||
if let Some(ref poem_session_id) = poem_session_id {
|
||||
let _ = session_storage.remove_session(&poem_session_id).await;
|
||||
}
|
||||
this.lock().await.session_handles.remove(&id);
|
||||
info!(%id, "Removed HTTP session");
|
||||
let mut that = this.lock().await;
|
||||
that.session_handles.remove(&id);
|
||||
that.session_timestamps.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ impl PublicKeyAsOpenSSH for KeyPair {
|
|||
let mut buf = String::new();
|
||||
buf.push_str(self.name());
|
||||
buf.push(' ');
|
||||
buf.push_str(&self.public_key_base64().replace("\r\n", ""));
|
||||
buf.push_str(&self.public_key_base64());
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,9 +13,8 @@ use std::net::SocketAddr;
|
|||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::*;
|
||||
use warpgate_common::{Services, SessionState};
|
||||
use warpgate_common::{Services, SessionStateInit};
|
||||
|
||||
pub async fn run_server(services: Services, address: SocketAddr) -> Result<()> {
|
||||
let russh_config = {
|
||||
|
@ -36,16 +35,18 @@ pub async fn run_server(services: Services, address: SocketAddr) -> Result<()> {
|
|||
let russh_config = russh_config.clone();
|
||||
|
||||
let (session_handle, session_handle_rx) = SSHSessionHandle::new();
|
||||
let session_state = Arc::new(Mutex::new(SessionState::new(
|
||||
Some(remote_address),
|
||||
Box::new(session_handle),
|
||||
)));
|
||||
|
||||
let server_handle = services
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.register_session(&crate::PROTOCOL_NAME, &session_state)
|
||||
.register_session(
|
||||
&crate::PROTOCOL_NAME,
|
||||
SessionStateInit {
|
||||
remote_address: Some(remote_address),
|
||||
handle: Box::new(session_handle),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id = server_handle.lock().await.id();
|
||||
|
|
|
@ -747,9 +747,10 @@ impl ServerSession {
|
|||
|
||||
async fn traffic_recorder_for(
|
||||
&mut self,
|
||||
host: &String,
|
||||
host: &str,
|
||||
port: u32,
|
||||
) -> Option<&mut TrafficRecorder> {
|
||||
let host = host.to_owned();
|
||||
if let Vacant(e) = self.traffic_recorders.entry((host.clone(), port)) {
|
||||
match self
|
||||
.services
|
||||
|
@ -767,7 +768,7 @@ impl ServerSession {
|
|||
}
|
||||
}
|
||||
}
|
||||
self.traffic_recorders.get_mut(&(host.clone(), port))
|
||||
self.traffic_recorders.get_mut(&(host, port))
|
||||
}
|
||||
|
||||
pub async fn _channel_shell_request(
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
"@typescript-eslint/eslint-plugin": "^5.28.0",
|
||||
"@typescript-eslint/parser": "^5.28.0",
|
||||
"bootstrap": "^5.2.0-beta1",
|
||||
"copy-text-to-clipboard": "^3.0.1",
|
||||
"eslint": "^8.18.0",
|
||||
"eslint-config-standard": "^16.0.3",
|
||||
"eslint-import-resolver-typescript": "^2.7.1",
|
||||
|
@ -41,6 +42,7 @@
|
|||
"svelte-check": "^2.7.2",
|
||||
"svelte-fa": "^3.0.1",
|
||||
"svelte-intersection-observer": "^0.10.0",
|
||||
"svelte-observable": "^0.4.0",
|
||||
"svelte-preprocess": "^4.10.7",
|
||||
"svelte-spa-router": "^3.2.0",
|
||||
"sveltestrap": "^5.9.0",
|
||||
|
|
|
@ -71,7 +71,6 @@ const routes = {
|
|||
<div class="username ms-auto">
|
||||
{$serverInfo?.username}
|
||||
</div>
|
||||
<ThemeSwitcher />
|
||||
<button class="btn btn-link" on:click={logout} title="Log out">
|
||||
<Fa icon={faSignOut} fw />
|
||||
</button>
|
||||
|
|
|
@ -1,20 +1,48 @@
|
|||
<script lang="ts">
|
||||
import Fa from 'svelte-fa'
|
||||
import { faCircleDot as iconActive } from '@fortawesome/free-regular-svg-icons'
|
||||
import { Spinner } from 'sveltestrap'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { link } from 'svelte-spa-router'
|
||||
import { api, SessionSnapshot } from 'admin/lib/api'
|
||||
import { derived, writable } from 'svelte/store'
|
||||
import { firstBy } from 'thenby'
|
||||
import moment from 'moment'
|
||||
import { timer, Observable, switchMap, from, combineLatest, fromEvent, merge } from 'rxjs'
|
||||
import RelativeDate from './RelativeDate.svelte'
|
||||
import AsyncButton from 'common/AsyncButton.svelte'
|
||||
import ItemList, { LoadOptions, PaginatedResponse } from 'common/ItemList.svelte'
|
||||
import { Input } from 'sveltestrap'
|
||||
import { autosave } from 'common/autosave'
|
||||
|
||||
const sessions = writable<SessionSnapshot[]|null>(null)
|
||||
let [showActiveOnly, showActiveOnly$] = autosave('sessions-list:show-active-only', false)
|
||||
let [showLoggedInOnly, showLoggedInOnly$] = autosave('sessions-list:show-logged-in-only', true)
|
||||
|
||||
async function reloadSessions (): Promise<void> {
|
||||
sessions.set(await api.getSessions())
|
||||
let activeSessionCount = 0
|
||||
|
||||
let socket = new WebSocket(`wss://${location.host}/@warpgate/admin/api/sessions/changes`)
|
||||
let sessionChanges$ = fromEvent(socket, 'message')
|
||||
onDestroy(() => socket.close())
|
||||
|
||||
function loadSessions (opt: LoadOptions): Observable<PaginatedResponse<SessionSnapshot>> {
|
||||
return combineLatest([
|
||||
showActiveOnly$,
|
||||
showLoggedInOnly$,
|
||||
merge(timer(0, 60000), sessionChanges$),
|
||||
]).pipe(switchMap(([activeOnly, loggedInOnly]) => {
|
||||
api.getSessions({
|
||||
activeOnly: true,
|
||||
limit: 1,
|
||||
}).then(response => {
|
||||
activeSessionCount = response.total
|
||||
})
|
||||
return from(api.getSessions({
|
||||
activeOnly,
|
||||
loggedInOnly,
|
||||
...opt,
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
async function _reloadSessions (): Promise<void> {
|
||||
activeSessionCount = (await api.getSessions({ activeOnly: true })).total
|
||||
}
|
||||
|
||||
async function closeAllSesssions () {
|
||||
|
@ -30,22 +58,15 @@
|
|||
return `${user} on ${target}`
|
||||
}
|
||||
|
||||
let activeSessions = derived(sessions, s => s?.filter(x => !x.ended).length ?? 0)
|
||||
let sortedSessions = derived(sessions, s => s?.sort(
|
||||
firstBy<SessionSnapshot, boolean>(x => !!x.ended, 'asc')
|
||||
.thenBy(x => x.ended ?? x.started, 'desc')
|
||||
))
|
||||
reloadSessions()
|
||||
const interval = setInterval(reloadSessions, 1000)
|
||||
_reloadSessions()
|
||||
const interval = setInterval(_reloadSessions, 1000000)
|
||||
onDestroy(() => clearInterval(interval))
|
||||
|
||||
</script>
|
||||
|
||||
{#if !$sessions}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<div class="page-summary-bar">
|
||||
{#if $activeSessions }
|
||||
<h1>Sessions right now: {$activeSessions}</h1>
|
||||
<div class="page-summary-bar">
|
||||
{#if activeSessionCount }
|
||||
<h1>Sessions right now: {activeSessionCount}</h1>
|
||||
<div class="ms-auto">
|
||||
<AsyncButton outline click={closeAllSesssions}>
|
||||
Close all sessions
|
||||
|
@ -54,12 +75,17 @@
|
|||
{:else}
|
||||
<h1>No active sessions</h1>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ItemList load={loadSessions} pageSize={100}>
|
||||
<div slot="header" class="d-flex align-items-center mb-1">
|
||||
<div class="ms-auto"></div>
|
||||
<Input class="ms-3" type="switch" label="Active only" bind:checked={$showActiveOnly} />
|
||||
<Input class="ms-3" type="switch" label="Logged in only" bind:checked={$showLoggedInOnly} />
|
||||
</div>
|
||||
|
||||
{#if $sortedSessions }
|
||||
<div class="list-group list-group-flush">
|
||||
{#each $sortedSessions as session}
|
||||
<a
|
||||
slot="item" let:item={session}
|
||||
class="list-group-item list-group-item-action"
|
||||
href="/sessions/{session.id}"
|
||||
use:link>
|
||||
|
@ -85,10 +111,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</ItemList>
|
||||
|
||||
<style lang="scss">
|
||||
.list-group-item {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { api, SSHKey, SSHKnownHost } from 'admin/lib/api'
|
||||
import CopyButton from 'common/CopyButton.svelte'
|
||||
import { Alert } from 'sveltestrap'
|
||||
|
||||
let error: Error|undefined
|
||||
|
@ -35,8 +36,11 @@ async function deleteHost (host: SSHKnownHost) {
|
|||
<Alert color="info">Add these keys to the targets' <code>authorized_hosts</code> files</Alert>
|
||||
<div class="list-group list-group-flush">
|
||||
{#each ownKeys as key}
|
||||
<div class="list-group-item">
|
||||
<div class="list-group-item d-flex">
|
||||
<pre>{key.kind} {key.publicKeyBase64}</pre>
|
||||
<div class="ms-auto">
|
||||
<CopyButton class="ms-3" link text={key.kind + ' ' + key.publicKeyBase64} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
@ -13,16 +13,74 @@
|
|||
"paths": {
|
||||
"/sessions": {
|
||||
"get": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "offset",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "uint64"
|
||||
},
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "uint64"
|
||||
},
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"name": "active_only",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"name": "logged_in_only",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"deprecated": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items",
|
||||
"offset",
|
||||
"total"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SessionSnapshot"
|
||||
}
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"format": "uint64"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"format": "uint64"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
60
warpgate-web/src/common/CopyButton.svelte
Normal file
60
warpgate-web/src/common/CopyButton.svelte
Normal file
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'
|
||||
import Fa from 'svelte-fa'
|
||||
import { Button } from 'sveltestrap'
|
||||
import copyTextToClipboard from 'copy-text-to-clipboard'
|
||||
import type { ButtonColor } from 'sveltestrap/src/Button'
|
||||
|
||||
export let text: string
|
||||
export let disabled = false
|
||||
export let outline = false
|
||||
export let link = false
|
||||
export let color: ButtonColor = 'link'
|
||||
let successVisible = false
|
||||
let button: HTMLElement
|
||||
|
||||
async function _click () {
|
||||
successVisible = true
|
||||
copyTextToClipboard(text)
|
||||
setTimeout(() => {
|
||||
successVisible = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
{#if link}
|
||||
<!-- svelte-ignore a11y-invalid-attribute -->
|
||||
<a
|
||||
href="#"
|
||||
class={$$props.class}
|
||||
on:click|preventDefault={_click}
|
||||
disabled={disabled}
|
||||
bind:this={button}
|
||||
>
|
||||
<slot>
|
||||
{#if successVisible}
|
||||
Copied
|
||||
{:else}
|
||||
Copy
|
||||
{/if}
|
||||
</slot>
|
||||
</a>
|
||||
{:else}
|
||||
<Button
|
||||
class={$$props.class}
|
||||
bind:inner={button}
|
||||
on:click={_click}
|
||||
outline={outline}
|
||||
color={color}
|
||||
disabled={disabled}
|
||||
>
|
||||
<slot>
|
||||
{#if successVisible}
|
||||
<Fa fw icon={faCheck} />
|
||||
{:else}
|
||||
<Fa fw icon={faCopy} />
|
||||
{/if}
|
||||
</slot>
|
||||
</Button>
|
||||
{/if}
|
72
warpgate-web/src/common/ItemList.svelte
Normal file
72
warpgate-web/src/common/ItemList.svelte
Normal file
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts" context="module">
|
||||
export interface LoadOptions {
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
offset: number
|
||||
total: number
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte'
|
||||
import { Subject, switchMap, map, Observable, distinctUntilChanged, share } from 'rxjs'
|
||||
import Pagination from './Pagination.svelte'
|
||||
import { observe } from 'svelte-observable'
|
||||
import { Spinner } from 'sveltestrap'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-type-alias
|
||||
type T = $$Generic
|
||||
|
||||
export let page = 0
|
||||
export let pageSize = 100
|
||||
export let load: (_: LoadOptions) => Observable<PaginatedResponse<T>>
|
||||
|
||||
const page$ = new Subject<number>()
|
||||
|
||||
const responses = page$.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap(p => {
|
||||
page = p
|
||||
return load({
|
||||
offset: p * pageSize,
|
||||
limit: pageSize,
|
||||
})
|
||||
}),
|
||||
share(),
|
||||
)
|
||||
|
||||
const total = observe<number>(responses.pipe(map(x => x.total)), 0)
|
||||
const items = observe<T[]|null>(responses.pipe(map(x => x.items)), null)
|
||||
|
||||
onDestroy(() => {
|
||||
page$.complete()
|
||||
})
|
||||
|
||||
$: page$.next(page)
|
||||
</script>
|
||||
|
||||
{#await $items}
|
||||
<Spinner />
|
||||
{:then items}
|
||||
{#if items}
|
||||
<slot name="header" items={items} />
|
||||
<div class="list-group list-group-flush mb-3">
|
||||
{#each items as item}
|
||||
<slot name="item" item={item} />
|
||||
{/each}
|
||||
</div>
|
||||
<slot name="footer" items={items} />
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
{#await $total then total}
|
||||
{#if total > pageSize}
|
||||
<Pagination total={total} bind:page={page} pageSize={pageSize} />
|
||||
{/if}
|
||||
{/await}
|
49
warpgate-web/src/common/Pagination.svelte
Normal file
49
warpgate-web/src/common/Pagination.svelte
Normal file
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { faAngleLeft, faAngleRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import Fa from 'svelte-fa'
|
||||
import { Pagination, PaginationItem, PaginationLink } from 'sveltestrap'
|
||||
|
||||
export let page = 0
|
||||
export let pageSize = 1
|
||||
export let total = 1
|
||||
|
||||
let pages: (number|null)[] = []
|
||||
|
||||
$: {
|
||||
let i = 0
|
||||
pages = []
|
||||
let totalPages = Math.floor((total - 1) / pageSize + 1)
|
||||
while (i < totalPages) {
|
||||
if (i < 2 || i > totalPages - 3 || Math.abs(i - page) < 3) {
|
||||
pages.push(i)
|
||||
} else if (pages[pages.length - 1]) {
|
||||
pages.push(null)
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Pagination>
|
||||
<PaginationItem disabled={page === 0}>
|
||||
<PaginationLink on:click={() => page--} href="#">
|
||||
<Fa icon={faAngleLeft} />
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
{#each pages as i}
|
||||
{#if i !== null}
|
||||
<PaginationItem active={page === i}>
|
||||
<PaginationLink on:click={() => page = i} href="#">{i + 1}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{:else}
|
||||
<PaginationItem disabled>
|
||||
<PaginationLink href="#">...</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
{/each}
|
||||
<PaginationItem disabled={(page + 1) * pageSize >= total}>
|
||||
<PaginationLink on:click={() => page++} href="#">
|
||||
<Fa icon={faAngleRight} />
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
</Pagination>
|
13
warpgate-web/src/common/autosave.ts
Normal file
13
warpgate-web/src/common/autosave.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { BehaviorSubject } from 'rxjs'
|
||||
import { get, writable, Writable } from 'svelte/store'
|
||||
|
||||
export function autosave<T> (key: string, initial: T): ([Writable<T>, BehaviorSubject<T>]) {
|
||||
key = `warpgate:${key}`
|
||||
const v = writable(JSON.parse(localStorage.getItem(key) ?? JSON.stringify(initial)))
|
||||
const v$ = new BehaviorSubject<T>(get(v))
|
||||
v.subscribe(value => {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
v$.next(value)
|
||||
})
|
||||
return [v, v$]
|
||||
}
|
|
@ -1,9 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import CopyButton from 'common/CopyButton.svelte'
|
||||
import { api, Target, TargetKind } from 'gateway/lib/api'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Fa from 'svelte-fa'
|
||||
import { Badge, FormGroup, Modal, ModalBody, ModalHeader, Spinner } from 'sveltestrap'
|
||||
import { FormGroup, Modal, ModalBody, ModalHeader, Spinner } from 'sveltestrap'
|
||||
import { serverInfo } from './lib/store'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
@ -13,6 +14,7 @@ let selectedTarget: Target|undefined
|
|||
let sshUsername: string
|
||||
|
||||
$: sshUsername = `${$serverInfo?.username}:${selectedTarget?.name}`
|
||||
$: exampleCommand = `ssh ${sshUsername}@warpgate-host -p ${$serverInfo?.ports.ssh}`
|
||||
|
||||
async function init () {
|
||||
targets = await api.getTargets()
|
||||
|
@ -32,6 +34,7 @@ function loadURL (url: string) {
|
|||
dispatch('navigation')
|
||||
location.href = url
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
</script>
|
||||
|
@ -80,12 +83,14 @@ init()
|
|||
{#if selectedTarget?.kind === TargetKind.Ssh}
|
||||
<h3>Connection instructions</h3>
|
||||
|
||||
<FormGroup floating label="SSH username">
|
||||
<FormGroup floating label="SSH username" class="d-flex align-items-center">
|
||||
<input type="text" class="form-control" readonly value={sshUsername} />
|
||||
<CopyButton text={sshUsername} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup floating label="Example command">
|
||||
<input type="text" class="form-control" readonly value={`ssh ${sshUsername}@warpgate-host -p ${$serverInfo?.ports.ssh}`} />
|
||||
<FormGroup floating label="Example command" class="d-flex align-items-center">
|
||||
<input type="text" class="form-control" readonly value={exampleCommand} />
|
||||
<CopyButton text={exampleCommand} />
|
||||
</FormGroup>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
// @import "bootstrap/scss/card";
|
||||
// @import "bootstrap/scss/accordion";
|
||||
// @import "bootstrap/scss/breadcrumb";
|
||||
// @import "bootstrap/scss/pagination";
|
||||
@import "bootstrap/scss/pagination";
|
||||
@import "bootstrap/scss/badge";
|
||||
@import "bootstrap/scss/alert";
|
||||
// @import "bootstrap/scss/progress";
|
||||
|
@ -88,3 +88,7 @@ input:-webkit-autofill:focus {
|
|||
.badge {
|
||||
font-family: $font-family-os;
|
||||
}
|
||||
|
||||
.page-item.active .page-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
@ -46,3 +46,7 @@ header {
|
|||
.list-group-item-action {
|
||||
transition: 0.125s ease-out background;
|
||||
}
|
||||
|
||||
body {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
|
|
@ -21,3 +21,7 @@ header {
|
|||
.alert {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
|
8
warpgate-web/src/theme/vars.common.scss
Normal file
8
warpgate-web/src/theme/vars.common.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
$pagination-bg: transparent;
|
||||
$pagination-disabled-bg: transparent;
|
||||
$pagination-disabled-color: $btn-link-disabled-color;
|
||||
$pagination-border-width: 0;
|
||||
$pagination-active-color: $link-hover-color;
|
||||
$pagination-active-bg: transparent;
|
||||
$pagination-hover-bg: transparent;
|
||||
$pagination-focus-bg: transparent;
|
|
@ -30,12 +30,13 @@ $component-active-bg: $primary;
|
|||
$input-bg: #ffffff08;
|
||||
$input-border-color: #ced4da40;
|
||||
$input-color: #ccc;
|
||||
|
||||
$input-focus-bg: #ffffff08;
|
||||
$input-focus-border-color: tint-color($component-active-bg, 25%) ;
|
||||
|
||||
$input-disabled-bg: $input-bg;
|
||||
|
||||
$form-check-input-border: 2px solid $input-border-color;
|
||||
$form-switch-color: $input-color;
|
||||
|
||||
$input-btn-focus-color-opacity: .25;
|
||||
$input-btn-border-width: 2px;
|
||||
|
||||
|
@ -58,6 +59,7 @@ $text-muted: rgba($body-color, .5);
|
|||
$modal-content-bg: $body-bg;
|
||||
|
||||
$btn-close-color: $secondary;
|
||||
$btn-link-disabled-color: rgba(255, 255, 255, .5);
|
||||
|
||||
$alert-bg-scale: 100%;
|
||||
$alert-border-scale: 50%;
|
||||
|
@ -66,3 +68,4 @@ $alert-color-scale: 0%;
|
|||
$code-color: #84f1fe;
|
||||
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "./vars.common.scss";
|
||||
|
|
|
@ -19,7 +19,10 @@ $green: #87C041 !default;
|
|||
$teal: #20c997 !default;
|
||||
$cyan: #0dcaf0 !default;
|
||||
|
||||
$form-check-input-border: 2px solid rgba(#000, .25);
|
||||
|
||||
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "./vars.common.scss";
|
||||
|
||||
$text-muted: $gray-500;
|
||||
|
|
|
@ -604,6 +604,11 @@ console.table@0.10.0:
|
|||
dependencies:
|
||||
easy-table "1.1.0"
|
||||
|
||||
copy-text-to-clipboard@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz#8cbf8f90e0a47f12e4a24743736265d157bce69c"
|
||||
integrity sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q==
|
||||
|
||||
cross-spawn@^7.0.2:
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
|
||||
|
@ -2267,6 +2272,11 @@ svelte-intersection-observer@^0.10.0:
|
|||
resolved "https://registry.yarnpkg.com/svelte-intersection-observer/-/svelte-intersection-observer-0.10.0.tgz#6f9ff6c235ee80b761c169406e389ff15d1e8f3a"
|
||||
integrity sha512-GdOTMSrRpoBciMe+NbocsHOBvqKJ5OiL5H8Jz4mBoDGHba9VOI4oGeXAmMOmh8mi5gq95+0b0DVI+6alcQ7TCA==
|
||||
|
||||
svelte-observable@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/svelte-observable/-/svelte-observable-0.4.0.tgz#74756e42fa3516c154d53f4244fa9cf691854224"
|
||||
integrity sha512-e8CfnkUfOZ/nAIpdFi+g63lS5oU+8J+KKhwkHpO0/reADeTBbIiVbcCqVrLsETp8+i+4ydQoawNCwMP/C8oMyA==
|
||||
|
||||
svelte-preprocess@^4.0.0, svelte-preprocess@^4.10.7:
|
||||
version "4.10.7"
|
||||
resolved "https://registry.yarnpkg.com/svelte-preprocess/-/svelte-preprocess-4.10.7.tgz#3626de472f51ffe20c9bc71eff5a3da66797c362"
|
||||
|
|
Loading…
Reference in a new issue