ui: added search boxes - #761

This commit is contained in:
Eugene Pankov 2023-05-18 21:57:55 +02:00
parent 5af6cf3597
commit a38fd2bbb1
No known key found for this signature in database
GPG key ID: 5896FCBBDD1CF4F4
10 changed files with 280 additions and 168 deletions

View file

@ -1,10 +1,13 @@
use std::sync::Arc;
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryOrder, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter,
QueryOrder, Set,
};
use tokio::sync::Mutex;
use uuid::Uuid;
use warpgate_common::{Role as RoleConfig, WarpgateError};
@ -38,11 +41,18 @@ impl ListApi {
async fn api_get_all_roles(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
search: Query<Option<String>>,
) -> poem::Result<GetRolesResponse> {
let db = db.lock().await;
let roles = Role::Entity::find()
.order_by_asc(Role::Column::Name)
let mut roles = Role::Entity::find().order_by_asc(Role::Column::Name);
if let Some(ref search) = *search {
let search = format!("%{}%", search);
roles = roles.filter(Role::Column::Name.like(&*search));
}
let roles = roles
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use sea_orm::{
@ -43,14 +43,18 @@ impl ListApi {
async fn api_get_all_targets(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
search: Query<Option<String>>,
) -> poem::Result<GetTargetsResponse> {
let db = db.lock().await;
let targets = Target::Entity::find()
.order_by_asc(Target::Column::Name)
.all(&*db)
.await
.map_err(WarpgateError::from)?;
let mut targets = Target::Entity::find().order_by_asc(Target::Column::Name);
if let Some(ref search) = *search {
let search = format!("%{}%", search);
targets = targets.filter(Target::Column::Name.like(&*search));
}
let targets = targets.all(&*db).await.map_err(WarpgateError::from)?;
let targets: Result<Vec<TargetConfig>, _> =
targets.into_iter().map(|t| t.try_into()).collect();

View file

@ -1,7 +1,7 @@
use std::sync::Arc;
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use sea_orm::{
@ -46,14 +46,18 @@ impl ListApi {
async fn api_get_all_users(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
search: Query<Option<String>>,
) -> poem::Result<GetUsersResponse> {
let db = db.lock().await;
let users = User::Entity::find()
.order_by_asc(User::Column::Username)
.all(&*db)
.await
.map_err(WarpgateError::from)?;
let mut users = User::Entity::find().order_by_asc(User::Column::Username);
if let Some(ref search) = *search {
let search = format!("%{}%", search);
users = users.filter(User::Column::Username.like(&*search));
}
let users = users.all(&*db).await.map_err(WarpgateError::from)?;
let users: Result<Vec<UserConfig>, _> = users.into_iter().map(|t| t.try_into()).collect();
let users = users.map_err(WarpgateError::from)?;

View file

@ -1,5 +1,6 @@
use futures::{stream, StreamExt};
use poem::web::Data;
use poem_openapi::param::Query;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use serde::Serialize;
@ -36,11 +37,20 @@ impl Api {
&self,
services: Data<&Services>,
auth: Data<&SessionAuthorization>,
search: Query<Option<String>>,
) -> poem::Result<GetTargetsResponse> {
let targets = {
let mut targets = {
let mut config_provider = services.config_provider.lock().await;
config_provider.list_targets().await?
};
if let Some(ref search) = *search {
targets = targets
.into_iter()
.filter(|t| t.name.contains(search))
.collect()
}
let mut targets = stream::iter(targets)
.filter(|t| {
let services = services.clone();

View file

@ -1,8 +1,38 @@
<script lang="ts">
import { api } from 'admin/lib/api'
import DelayedSpinner from 'common/DelayedSpinner.svelte'
import { link } from 'svelte-spa-router'
import { Alert } from 'sveltestrap'
import { Observable, from, map } from 'rxjs'
import { Role, Target, User, api } from 'admin/lib/api'
import ItemList, { LoadOptions, PaginatedResponse } from 'common/ItemList.svelte'
import { link } from 'svelte-spa-router'
function getTargets (options: LoadOptions): Observable<PaginatedResponse<Target>> {
return from(api.getTargets({
search: options.search,
})).pipe(map(targets => ({
items: targets,
offset: 0,
total: targets.length,
})))
}
function getUsers (options: LoadOptions): Observable<PaginatedResponse<User>> {
return from(api.getUsers({
search: options.search,
})).pipe(map(targets => ({
items: targets,
offset: 0,
total: targets.length,
})))
}
function getRoles (options: LoadOptions): Observable<PaginatedResponse<Role>> {
return from(api.getRoles({
search: options.search,
})).pipe(map(targets => ({
items: targets,
offset: 0,
total: targets.length,
})))
}
</script>
<div class="row">
@ -17,39 +47,32 @@ import { Alert } from 'sveltestrap'
</a>
</div>
{#await api.getTargets()}
<DelayedSpinner />
{:then targets}
<div class="list-group list-group-flush">
{#each targets as target}
<!-- svelte-ignore a11y-missing-attribute -->
<a
class="list-group-item list-group-item-action"
href="/targets/{target.id}"
use:link>
<strong class="me-auto">
{target.name}
</strong>
<small class="text-muted ms-auto">
{#if target.options.kind === 'Http'}
HTTP
{/if}
{#if target.options.kind === 'MySql'}
MySQL
{/if}
{#if target.options.kind === 'Ssh'}
SSH
{/if}
{#if target.options.kind === 'WebAdmin'}
This web admin interface
{/if}
</small>
</a>
{/each}
</div>
{:catch error}
<Alert color="danger">{error}</Alert>
{/await}
<ItemList load={getTargets} showSearch={true}>
<!-- svelte-ignore a11y-missing-attribute -->
<a
slot="item" let:item={target}
class="list-group-item list-group-item-action"
href="/targets/{target.id}"
use:link>
<strong class="me-auto">
{target.name}
</strong>
<small class="text-muted ms-auto">
{#if target.options.kind === 'Http'}
HTTP
{/if}
{#if target.options.kind === 'MySql'}
MySQL
{/if}
{#if target.options.kind === 'Ssh'}
SSH
{/if}
{#if target.options.kind === 'WebAdmin'}
This web admin interface
{/if}
</small>
</a>
</ItemList>
</div>
<div class="col-12 col-lg-6 pe-4">
@ -63,25 +86,18 @@ import { Alert } from 'sveltestrap'
</a>
</div>
{#await api.getUsers()}
<DelayedSpinner />
{:then users}
<div class="list-group list-group-flush">
{#each users as user}
<!-- svelte-ignore a11y-missing-attribute -->
<a
class="list-group-item list-group-item-action"
href="/users/{user.id}"
use:link>
<strong class="me-auto">
{user.username}
</strong>
</a>
{/each}
</div>
{:catch error}
<Alert color="danger">{error}</Alert>
{/await}
<ItemList load={getUsers} showSearch={true}>
<!-- svelte-ignore a11y-missing-attribute -->
<a
slot="item" let:item={user}
class="list-group-item list-group-item-action"
href="/users/{user.id}"
use:link>
<strong class="me-auto">
{user.username}
</strong>
</a>
</ItemList>
<div class="page-summary-bar mt-4">
<h1>Roles</h1>
@ -93,25 +109,18 @@ import { Alert } from 'sveltestrap'
</a>
</div>
{#await api.getRoles()}
<DelayedSpinner />
{:then roles}
<div class="list-group list-group-flush">
{#each roles as role}
<!-- svelte-ignore a11y-missing-attribute -->
<a
class="list-group-item list-group-item-action"
href="/roles/{role.id}"
use:link>
<strong class="me-auto">
{role.name}
</strong>
</a>
{/each}
</div>
{:catch error}
<Alert color="danger">{error}</Alert>
{/await}
<ItemList load={getRoles} showSearch={true}>
<!-- svelte-ignore a11y-missing-attribute -->
<a
slot="item" let:item={role}
class="list-group-item list-group-item-action"
href="/roles/{role.id}"
use:link>
<strong class="me-auto">
{role.name}
</strong>
</a>
</ItemList>
</div>
</div>

View file

@ -80,7 +80,7 @@
{/if}
<ItemList load={loadSessions} pageSize={100}>
<div slot="header" class="d-flex align-items-center mb-1">
<div slot="header" class="d-flex align-items-center mb-1 w-100">
<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} />

View file

@ -2,7 +2,7 @@
"openapi": "3.0.0",
"info": {
"title": "Warpgate Web Admin",
"version": "0.7.0"
"version": "0.7.1"
},
"servers": [
{
@ -207,6 +207,18 @@
},
"/roles": {
"get": {
"parameters": [
{
"name": "search",
"schema": {
"type": "string"
},
"in": "query",
"required": false,
"deprecated": false,
"explode": true
}
],
"responses": {
"200": {
"description": "",
@ -366,6 +378,18 @@
},
"/targets": {
"get": {
"parameters": [
{
"name": "search",
"schema": {
"type": "string"
},
"in": "query",
"required": false,
"deprecated": false,
"explode": true
}
],
"responses": {
"200": {
"description": "",
@ -636,6 +660,18 @@
},
"/users": {
"get": {
"parameters": [
{
"name": "search",
"schema": {
"type": "string"
},
"in": "query",
"required": false,
"deprecated": false,
"explode": true
}
],
"responses": {
"200": {
"description": "",

View file

@ -1,7 +1,8 @@
<script lang="ts" context="module">
export interface LoadOptions {
search?: string
offset: number
limit: number
limit?: number
}
export interface PaginatedResponse<T> {
@ -13,30 +14,49 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import { Subject, switchMap, map, Observable, distinctUntilChanged, share } from 'rxjs'
import { Subject, switchMap, map, Observable, distinctUntilChanged, share, combineLatest, tap, debounceTime } from 'rxjs'
import Pagination from './Pagination.svelte'
import { observe } from 'svelte-observable'
import { Input } from 'sveltestrap'
import DelayedSpinner from './DelayedSpinner.svelte'
// eslint-disable-next-line @typescript-eslint/no-type-alias
type T = $$Generic
export let page = 0
export let pageSize = 100
export let pageSize: number|undefined = undefined
export let load: (_: LoadOptions) => Observable<PaginatedResponse<T>>
export let showSearch = false
let filter = ''
let loaded = false
const page$ = new Subject<number>()
const filter$ = new Subject<string>()
const responses = page$.pipe(
const responses = combineLatest([
page$,
filter$.pipe(
tap(() => {
loaded = false
}),
debounceTime(200),
),
]).pipe(
distinctUntilChanged(),
switchMap(p => {
switchMap(([p, f]) => {
page = p
loaded = false
return load({
offset: p * pageSize,
search: f,
offset: p * (pageSize ?? 0),
limit: pageSize,
})
}),
share(),
tap(() => {
loaded = true
}),
)
const total = observe<number>(responses.pipe(map(x => x.total)), 0)
@ -44,16 +64,27 @@
onDestroy(() => {
page$.complete()
filter$.complete()
})
$: page$.next(page)
$: filter$.next(filter)
filter$.subscribe(() => {
page = 0
})
</script>
<div class="d-flex mb-2" hidden={!loaded}>
{#if showSearch}
<Input bind:value={filter} placeholder="Search..." class="flex-grow-1 border-0" />
{/if}
<slot name="header" items={items} />
</div>
{#await $items}
<DelayedSpinner />
{: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} />
@ -63,10 +94,16 @@
{:else}
<DelayedSpinner />
{/if}
{#if filter && loaded && !items?.length}
<em>
Nothing found
</em>
{/if}
{/await}
{#await $total then total}
{#if total > pageSize}
{#if pageSize && total > pageSize}
<Pagination total={total} bind:page={page} pageSize={pageSize} />
{/if}
{/await}

View file

@ -1,100 +1,90 @@
<script lang="ts">
import { Observable, from, map } from 'rxjs'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import ConnectionInstructions from 'common/ConnectionInstructions.svelte'
import DelayedSpinner from 'common/DelayedSpinner.svelte'
import ItemList, { LoadOptions, PaginatedResponse } from 'common/ItemList.svelte'
import { api, TargetSnapshot, TargetKind } from 'gateway/lib/api'
import { createEventDispatcher } from 'svelte'
import Fa from 'svelte-fa'
import { Modal, ModalBody, ModalHeader } from 'sveltestrap'
import { serverInfo } from './lib/store'
import { firstBy } from 'thenby'
const dispatch = createEventDispatcher()
let targets: TargetSnapshot[]|undefined
let haveAdminTarget = false
let selectedTarget: TargetSnapshot|undefined
async function init () {
targets = await api.getTargets()
haveAdminTarget = targets.some(t => t.kind === TargetKind.WebAdmin)
targets = targets.filter(t => t.kind !== TargetKind.WebAdmin)
function loadTargets (options: LoadOptions): Observable<PaginatedResponse<TargetSnapshot>> {
return from(api.getTargets({ search: options.search })).pipe(
map(result => {
result = result.sort(
firstBy<TargetSnapshot, boolean>(x => x.kind !== TargetKind.WebAdmin)
.thenBy(x => x.name.toLowerCase())
)
return {
items: result,
offset: 0,
total: result.length,
}
})
)
}
function selectTarget (target: TargetSnapshot) {
if (target.kind === TargetKind.Http) {
if (target.kind === TargetKind.WebAdmin) {
loadURL('/@warpgate/admin')
} else if (target.kind === TargetKind.Http) {
loadURL(`/?warpgate-target=${target.name}`)
} else {
selectedTarget = target
}
}
function selectAdminTarget () {
loadURL('/@warpgate/admin')
}
function loadURL (url: string) {
dispatch('navigation')
location.href = url
}
init()
</script>
{#if targets}
<div class="list-group list-group-flush">
{#if haveAdminTarget}
<a
class="list-group-item list-group-item-action target-item"
href="/@warpgate/admin"
on:click|preventDefault={e => {
if (e.metaKey || e.ctrlKey) {
return
}
selectAdminTarget()
}}
>
<span class="me-auto">
Manage Warpgate
</span>
<Fa icon={faArrowRight} fw />
</a>
{/if}
{#each targets as target}
<a
class="list-group-item list-group-item-action target-item"
href={
target.kind === TargetKind.Http
? `/?warpgate-target=${target.name}`
: '/@warpgate/admin'
<ItemList load={loadTargets} showSearch={true}>
<a
slot="item" let:item={target}
class="list-group-item list-group-item-action target-item"
href={
target.kind === TargetKind.WebAdmin
? '/@warpgate/admin'
: target.kind === TargetKind.Http
? `/?warpgate-target=${target.name}`
: '/@warpgate/admin'
}
on:click|preventDefault={e => {
if (e.metaKey || e.ctrlKey) {
return
}
on:click|preventDefault={e => {
if (e.metaKey || e.ctrlKey) {
return
}
selectTarget(target)
}}
>
<span class="me-auto">
selectTarget(target)
}}
>
<span class="me-auto">
{#if target.kind === TargetKind.WebAdmin}
Manage Warpgate
{:else}
{target.name}
</span>
<small class="protocol text-muted ms-auto">
{#if target.kind === TargetKind.Ssh}
SSH
{/if}
{#if target.kind === TargetKind.MySql}
MySQL
{/if}
</small>
{#if target.kind === TargetKind.Http}
<Fa icon={faArrowRight} fw />
{/if}
</a>
{/each}
</div>
{:else}
<DelayedSpinner />
{/if}
</span>
<small class="protocol text-muted ms-auto">
{#if target.kind === TargetKind.Ssh}
SSH
{/if}
{#if target.kind === TargetKind.MySql}
MySQL
{/if}
</small>
{#if target.kind === TargetKind.Http || target.kind === TargetKind.WebAdmin}
<Fa icon={faArrowRight} fw />
{/if}
</a>
</ItemList>
<Modal isOpen={!!selectedTarget} toggle={() => selectedTarget = undefined}>
<ModalHeader toggle={() => selectedTarget = undefined}>

View file

@ -2,7 +2,7 @@
"openapi": "3.0.0",
"info": {
"title": "Warpgate HTTP proxy",
"version": "0.7.0"
"version": "0.7.1"
},
"servers": [
{
@ -237,6 +237,18 @@
},
"/targets": {
"get": {
"parameters": [
{
"name": "search",
"schema": {
"type": "string"
},
"in": "query",
"required": false,
"deprecated": false,
"explode": true
}
],
"responses": {
"200": {
"description": "",