Paginate sessions list, added filtering (#161)

This commit is contained in:
Eugene 2022-07-03 11:56:41 +02:00 committed by GitHub
parent a3a8a60156
commit 6830c0c20d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 590 additions and 128 deletions

4
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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;

View 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,
})
}
}

View file

@ -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>(())
})
}

View file

@ -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)))

View file

@ -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)

View file

@ -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);

View file

@ -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::*;

View file

@ -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;

View file

@ -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(());
}
}

View file

@ -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;

View file

@ -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(),
))

View file

@ -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);
}
}
}

View file

@ -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
}
}

View file

@ -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();

View file

@ -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(

View file

@ -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",

View file

@ -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>

View file

@ -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 {

View file

@ -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>

View file

@ -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"
}
}
}
}
}

View 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}

View 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}

View 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>

View 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$]
}

View file

@ -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>

View file

@ -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;
}

View file

@ -46,3 +46,7 @@ header {
.list-group-item-action {
transition: 0.125s ease-out background;
}
body {
color-scheme: dark;
}

View file

@ -21,3 +21,7 @@ header {
.alert {
background: none !important;
}
body {
color-scheme: light;
}

View 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;

View file

@ -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";

View file

@ -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;

View file

@ -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"