Add target groups for improved interface (#1518)

Co-authored-by: Eugene <inbox@null.page>
This commit is contained in:
Skyler Lewis
2025-11-27 17:09:46 -07:00
committed by GitHub
parent 0e3f147a61
commit 5baa4a931a
26 changed files with 1364 additions and 51 deletions

View File

@@ -16,6 +16,7 @@ mod ssh_connection_test;
mod ssh_keys;
mod sso_credentials;
mod targets;
mod target_groups;
mod tickets_detail;
mod tickets_list;
pub mod users;
@@ -32,6 +33,7 @@ pub fn get() -> impl OpenApi {
ssh_keys::Api,
logs::Api,
(targets::ListApi, targets::DetailApi, targets::RolesApi),
(target_groups::ListApi, target_groups::DetailApi),
(users::ListApi, users::DetailApi, users::RolesApi),
(
password_credentials::ListApi,

View File

@@ -0,0 +1,230 @@
use std::sync::Arc;
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use sea_orm::prelude::Expr;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter,
QueryOrder, Set,
};
use tokio::sync::Mutex;
use uuid::Uuid;
use warpgate_common::WarpgateError;
use warpgate_db_entities::TargetGroup;
use warpgate_db_entities::TargetGroup::BootstrapThemeColor;
use super::AnySecurityScheme;
#[derive(Object)]
struct TargetGroupDataRequest {
name: String,
description: Option<String>,
color: Option<BootstrapThemeColor>,
}
#[derive(ApiResponse)]
enum GetTargetGroupsResponse {
#[oai(status = 200)]
Ok(Json<Vec<TargetGroup::Model>>),
}
#[derive(ApiResponse)]
enum CreateTargetGroupResponse {
#[oai(status = 201)]
Created(Json<TargetGroup::Model>),
#[oai(status = 409)]
Conflict(Json<String>),
#[oai(status = 400)]
BadRequest(Json<String>),
}
pub struct ListApi;
#[OpenApi]
impl ListApi {
#[oai(
path = "/target-groups",
method = "get",
operation_id = "list_target_groups"
)]
async fn api_list_target_groups(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
_sec_scheme: AnySecurityScheme,
) -> Result<GetTargetGroupsResponse, WarpgateError> {
let db = db.lock().await;
let groups = TargetGroup::Entity::find()
.order_by_asc(TargetGroup::Column::Name)
.all(&*db)
.await?;
Ok(GetTargetGroupsResponse::Ok(Json(groups)))
}
#[oai(
path = "/target-groups",
method = "post",
operation_id = "create_target_group"
)]
async fn api_create_target_group(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
body: Json<TargetGroupDataRequest>,
_sec_scheme: AnySecurityScheme,
) -> Result<CreateTargetGroupResponse, WarpgateError> {
if body.name.is_empty() {
return Ok(CreateTargetGroupResponse::BadRequest(Json("name".into())));
}
let db = db.lock().await;
let existing = TargetGroup::Entity::find()
.filter(TargetGroup::Column::Name.eq(body.name.clone()))
.one(&*db)
.await?;
if existing.is_some() {
return Ok(CreateTargetGroupResponse::Conflict(Json(
"Name already exists".into(),
)));
}
let values = TargetGroup::ActiveModel {
id: Set(Uuid::new_v4()),
name: Set(body.name.clone()),
description: Set(body.description.clone().unwrap_or_default()),
color: Set(body.color.clone()),
};
let group = values.insert(&*db).await?;
Ok(CreateTargetGroupResponse::Created(Json(group)))
}
}
#[derive(ApiResponse)]
enum GetTargetGroupResponse {
#[oai(status = 200)]
Ok(Json<TargetGroup::Model>),
#[oai(status = 404)]
NotFound,
}
#[derive(ApiResponse)]
enum UpdateTargetGroupResponse {
#[oai(status = 200)]
Ok(Json<TargetGroup::Model>),
#[oai(status = 400)]
BadRequest,
#[oai(status = 404)]
NotFound,
}
#[derive(ApiResponse)]
enum DeleteTargetGroupResponse {
#[oai(status = 204)]
Deleted,
#[oai(status = 404)]
NotFound,
}
pub struct DetailApi;
#[OpenApi]
impl DetailApi {
#[oai(
path = "/target-groups/:id",
method = "get",
operation_id = "get_target_group"
)]
async fn api_get_target_group(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
_sec_scheme: AnySecurityScheme,
) -> Result<GetTargetGroupResponse, WarpgateError> {
let db = db.lock().await;
let group = TargetGroup::Entity::find_by_id(id.0).one(&*db).await?;
match group {
Some(group) => Ok(GetTargetGroupResponse::Ok(Json(group))),
None => Ok(GetTargetGroupResponse::NotFound),
}
}
#[oai(
path = "/target-groups/:id",
method = "put",
operation_id = "update_target_group"
)]
async fn api_update_target_group(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
body: Json<TargetGroupDataRequest>,
_sec_scheme: AnySecurityScheme,
) -> Result<UpdateTargetGroupResponse, WarpgateError> {
if body.name.is_empty() {
return Ok(UpdateTargetGroupResponse::BadRequest);
}
let db = db.lock().await;
let group = TargetGroup::Entity::find_by_id(id.0).one(&*db).await?;
let Some(group) = group else {
return Ok(UpdateTargetGroupResponse::NotFound);
};
// Check if name is already taken by another group
let existing = TargetGroup::Entity::find()
.filter(TargetGroup::Column::Name.eq(body.name.clone()))
.filter(TargetGroup::Column::Id.ne(id.0))
.one(&*db)
.await?;
if existing.is_some() {
return Ok(UpdateTargetGroupResponse::BadRequest);
}
let mut group: TargetGroup::ActiveModel = group.into();
group.name = Set(body.name.clone());
group.description = Set(body.description.clone().unwrap_or_default());
group.color = Set(body.color.clone());
let group = group.update(&*db).await?;
Ok(UpdateTargetGroupResponse::Ok(Json(group)))
}
#[oai(
path = "/target-groups/:id",
method = "delete",
operation_id = "delete_target_group"
)]
async fn api_delete_target_group(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
_sec_scheme: AnySecurityScheme,
) -> Result<DeleteTargetGroupResponse, WarpgateError> {
let db = db.lock().await;
let group = TargetGroup::Entity::find_by_id(id.0).one(&*db).await?;
let Some(group) = group else {
return Ok(DeleteTargetGroupResponse::NotFound);
};
// First, unassign all targets from this group by setting their group_id to NULL
use warpgate_db_entities::Target;
Target::Entity::update_many()
.col_expr(Target::Column::GroupId, Expr::value(Option::<Uuid>::None))
.filter(Target::Column::GroupId.eq(id.0))
.exec(&*db)
.await?;
// Then delete the group
group.delete(&*db).await?;
Ok(DeleteTargetGroupResponse::Deleted)
}
}

View File

@@ -26,6 +26,7 @@ struct TargetDataRequest {
description: Option<String>,
options: TargetOptions,
rate_limit_bytes_per_second: Option<u32>,
group_id: Option<Uuid>,
}
#[derive(ApiResponse)]
@@ -55,6 +56,7 @@ impl ListApi {
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
search: Query<Option<String>>,
group_id: Query<Option<Uuid>>,
_sec_scheme: AnySecurityScheme,
) -> Result<GetTargetsResponse, WarpgateError> {
let db = db.lock().await;
@@ -66,6 +68,10 @@ impl ListApi {
targets = targets.filter(Target::Column::Name.like(search));
}
if let Some(group_id) = *group_id {
targets = targets.filter(Target::Column::GroupId.eq(group_id));
}
let targets = targets.all(&*db).await.map_err(WarpgateError::from)?;
let targets: Result<Vec<TargetConfig>, _> =
@@ -108,6 +114,7 @@ impl ListApi {
kind: Set((&body.options).into()),
options: Set(serde_json::to_value(body.options.clone()).map_err(WarpgateError::from)?),
rate_limit_bytes_per_second: Set(None),
group_id: Set(body.group_id),
};
let target = values.insert(&*db).await.map_err(WarpgateError::from)?;
@@ -204,6 +211,7 @@ impl DetailApi {
model.options =
Set(serde_json::to_value(body.options.clone()).map_err(WarpgateError::from)?);
model.rate_limit_bytes_per_second = Set(body.rate_limit_bytes_per_second.map(|x| x as i64));
model.group_id = Set(body.group_id);
let target = model.update(&*db).await?;
drop(db);

View File

@@ -142,6 +142,7 @@ pub struct Target {
#[serde(flatten)]
pub options: TargetOptions,
pub rate_limit_bytes_per_second: Option<u32>,
pub group_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Union)]

View File

@@ -119,6 +119,7 @@ pub async fn populate_db(
))
.map_err(WarpgateError::from)?),
rate_limit_bytes_per_second: Set(None),
group_id: Set(None),
};
values.insert(&*db).await.map_err(WarpgateError::from)?

View File

@@ -52,6 +52,7 @@ pub struct Model {
pub kind: TargetKind,
pub options: serde_json::Value,
pub rate_limit_bytes_per_second: Option<i64>,
pub group_id: Option<Uuid>,
}
impl Related<super::Role::Entity> for Entity {
@@ -65,7 +66,17 @@ impl Related<super::Role::Entity> for Entity {
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
pub enum Relation {
#[sea_orm(belongs_to = "super::TargetGroup::Entity", from = "Column::GroupId", to = "super::TargetGroup::Column::Id")]
TargetGroup,
}
impl Related<super::TargetGroup::Entity> for Entity {
fn to() -> RelationDef {
Relation::TargetGroup.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
@@ -81,6 +92,7 @@ impl TryFrom<Model> for Target {
allow_roles: vec![],
options,
rate_limit_bytes_per_second: model.rate_limit_bytes_per_second.map(|v| v as u32),
group_id: model.group_id,
})
}
}

View File

@@ -0,0 +1,46 @@
use poem_openapi::{Enum, Object};
use sea_orm::entity::prelude::*;
use serde::Serialize;
use uuid::Uuid;
#[derive(Debug, PartialEq, Eq, Serialize, Clone, Enum, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")]
pub enum BootstrapThemeColor {
#[sea_orm(string_value = "primary")]
Primary,
#[sea_orm(string_value = "secondary")]
Secondary,
#[sea_orm(string_value = "success")]
Success,
#[sea_orm(string_value = "danger")]
Danger,
#[sea_orm(string_value = "warning")]
Warning,
#[sea_orm(string_value = "info")]
Info,
#[sea_orm(string_value = "light")]
Light,
#[sea_orm(string_value = "dark")]
Dark,
}
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]
#[sea_orm(table_name = "target_groups")]
#[oai(rename = "TargetGroup")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
#[sea_orm(column_type = "Text")]
pub description: String,
pub color: Option<BootstrapThemeColor>, // Bootstrap theme color for UI display
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::Target::Entity")]
Target,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -12,6 +12,7 @@ pub mod Role;
pub mod Session;
pub mod SsoCredential;
pub mod Target;
pub mod TargetGroup;
pub mod TargetRoleAssignment;
pub mod Ticket;
pub mod User;

View File

@@ -21,6 +21,7 @@ mod m00016_fix_public_key_length;
mod m00017_descriptions;
mod m00018_ticket_description;
mod m00019_rate_limits;
mod m00020_target_groups;
pub struct Migrator;
@@ -47,6 +48,7 @@ impl MigratorTrait for Migrator {
Box::new(m00017_descriptions::Migration),
Box::new(m00018_ticket_description::Migration),
Box::new(m00019_rate_limits::Migration),
Box::new(m00020_target_groups::Migration),
]
}
}

View File

@@ -0,0 +1,104 @@
use sea_orm::Schema;
use sea_orm_migration::prelude::*;
use crate::m00007_targets_and_roles::target;
pub mod target_group {
use sea_orm::entity::prelude::*;
use uuid::Uuid;
#[derive(Debug, PartialEq, Eq, Clone, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")]
pub enum BootstrapThemeColor {
#[sea_orm(string_value = "primary")]
Primary,
#[sea_orm(string_value = "secondary")]
Secondary,
#[sea_orm(string_value = "success")]
Success,
#[sea_orm(string_value = "danger")]
Danger,
#[sea_orm(string_value = "warning")]
Warning,
#[sea_orm(string_value = "info")]
Info,
#[sea_orm(string_value = "light")]
Light,
#[sea_orm(string_value = "dark")]
Dark,
}
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "target_groups")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
#[sea_orm(column_type = "Text")]
pub description: String,
pub color: Option<BootstrapThemeColor>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m00020_target_groups"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Create target_groups table
let builder = manager.get_database_backend();
let schema = Schema::new(builder);
manager
.create_table(schema.create_table_from_entity(target_group::Entity))
.await?;
// Add group_id column to targets table
manager
.alter_table(
Table::alter()
.table(target::Entity)
.add_column(
ColumnDef::new(Alias::new("group_id"))
.uuid()
.null(),
)
.to_owned(),
)
.await?;
// Note: SQLite doesn't support adding foreign key constraints to existing tables
// The foreign key relationship will be enforced at the application level
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Remove group_id column from targets table
manager
.alter_table(
Table::alter()
.table(target::Entity)
.drop_column(Alias::new("group_id"))
.to_owned(),
)
.await?;
// Drop target_groups table
manager
.drop_table(Table::drop().table(target_group::Entity).to_owned())
.await?;
Ok(())
}
}

View File

@@ -1,24 +1,36 @@
use std::collections::HashMap;
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 sea_orm::EntityTrait;
use serde::Serialize;
use warpgate_common::TargetOptions;
use warpgate_common::{TargetOptions, WarpgateError};
use warpgate_core::{ConfigProvider, Services};
use warpgate_db_entities::Target;
use warpgate_db_entities::TargetGroup::BootstrapThemeColor;
use warpgate_db_entities::{Target, TargetGroup};
use crate::api::AnySecurityScheme;
use crate::common::{endpoint_auth, RequestAuthorization, SessionAuthorization};
pub struct Api;
#[derive(Debug, Serialize, Clone, Object)]
pub struct GroupInfo {
pub id: uuid::Uuid,
pub name: String,
pub color: Option<BootstrapThemeColor>,
}
#[derive(Debug, Serialize, Clone, Object)]
pub struct TargetSnapshot {
pub name: String,
pub description: String,
pub kind: Target::TargetKind,
pub external_host: Option<String>,
pub group: Option<GroupInfo>,
}
#[derive(ApiResponse)]
@@ -41,7 +53,16 @@ impl Api {
auth: Data<&RequestAuthorization>,
search: Query<Option<String>>,
_sec_scheme: AnySecurityScheme,
) -> poem::Result<GetTargetsResponse> {
) -> Result<GetTargetsResponse, WarpgateError> {
// Fetch target groups for group information
let groups: Vec<TargetGroup::Model> = {
let db = services.db.lock().await;
TargetGroup::Entity::find().all(&*db).await
}?;
let group_map: HashMap<uuid::Uuid, &TargetGroup::Model> =
groups.iter().map(|g| (g.id, g)).collect();
let mut targets = {
let mut config_provider = services.config_provider.lock().await;
config_provider.list_targets().await?
@@ -49,10 +70,16 @@ impl Api {
if let Some(ref search) = *search {
let search = search.to_lowercase();
targets.retain(|t| t.name.to_lowercase().contains(&search))
targets.retain(|t| {
let group = t.group_id.and_then(|group_id| group_map.get(&group_id));
return t.name.to_lowercase().contains(&search)
|| group
.map(|g| g.name.to_lowercase().contains(&search))
.unwrap_or(false);
})
}
let mut targets = stream::iter(targets)
let targets = stream::iter(targets)
.filter(|t| {
let services = services.clone();
let auth = auth.clone();
@@ -78,12 +105,19 @@ impl Api {
})
.collect::<Vec<_>>()
.await;
targets.sort_by(|a, b| a.name.cmp(&b.name));
Ok(GetTargetsResponse::Ok(Json(
targets
.into_iter()
.map(|t| TargetSnapshot {
let result: Vec<TargetSnapshot> = targets
.into_iter()
.map(|t| {
let group = t.group_id.and_then(|group_id| {
group_map.get(&group_id).map(|group| GroupInfo {
id: group.id,
name: group.name.clone(),
color: group.color.clone(),
})
});
TargetSnapshot {
name: t.name.clone(),
description: t.description.clone(),
kind: (&t.options).into(),
@@ -91,8 +125,11 @@ impl Api {
TargetOptions::Http(ref opt) => opt.external_host.clone(),
_ => None,
},
})
.collect(),
)))
group,
}
})
.collect();
Ok(GetTargetsResponse::Ok(Json(result)))
}
}

View File

@@ -43,6 +43,15 @@
'/tickets/create': wrap({
asyncComponent: () => import('./CreateTicket.svelte') as any,
}),
'/target-groups/create': wrap({
asyncComponent: () => import('./target-groups/CreateTargetGroup.svelte') as any,
}),
'/target-groups/:id': wrap({
asyncComponent: () => import('./target-groups/TargetGroup.svelte') as any,
}),
'/target-groups': wrap({
asyncComponent: () => import('./target-groups/TargetGroups.svelte') as any,
}),
}
let sidebarMode = $state(false)
@@ -57,6 +66,14 @@
small={sidebarMode}
/>
<NavListItem
class="mb-2"
title="Target Groups"
description="Organize targets into groups"
href="/config/target-groups"
small={sidebarMode}
/>
<NavListItem
class="mb-2"
title="Users"

View File

@@ -1,15 +1,18 @@
<script lang="ts">
import { api, type TargetOptions, TlsMode } from 'admin/lib/api'
import { api, type TargetOptions, type TargetGroup, TlsMode } from 'admin/lib/api'
import { replace } from 'svelte-spa-router'
import { Button, ButtonGroup, Form, FormGroup } from '@sveltestrap/sveltestrap'
import { stringifyError } from 'common/errors'
import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'
import { TargetKind } from 'gateway/lib/api'
import RadioButton from 'common/RadioButton.svelte'
import { onMount } from 'svelte'
let error: string|null = $state(null)
let name = $state('')
let type: TargetKind = $state(TargetKind.Ssh)
let groups: TargetGroup[] = $state([])
let selectedGroupId: string | undefined = $state()
async function create () {
try {
@@ -62,6 +65,7 @@
targetDataRequest: {
name,
options,
groupId: selectedGroupId,
},
})
replace(`/config/targets/${target.id}`)
@@ -70,6 +74,14 @@
}
}
onMount(async () => {
try {
groups = await api.listTargetGroups()
} catch (err) {
error = await stringifyError(err)
}
})
const kinds: { name: string, value: TargetKind }[] = [
{ name: 'SSH', value: TargetKind.Ssh },
{ name: 'HTTP', value: TargetKind.Http },
@@ -111,6 +123,17 @@
<input class="form-control" required bind:value={name} />
</FormGroup>
{#if groups.length > 0}
<FormGroup floating label="Group">
<select class="form-control" bind:value={selectedGroupId}>
<option value={undefined}>No group</option>
{#each groups as group (group.id)}
<option value={group.id}>{group.name}</option>
{/each}
</select>
</FormGroup>
{/if}
<Button
color="primary"
type="submit"

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { api, type BootstrapThemeColor } from 'admin/lib/api'
import { link, replace } from 'svelte-spa-router'
import { FormGroup, Input, Label, Alert } from '@sveltestrap/sveltestrap'
import { stringifyError } from 'common/errors'
import GroupColorCircle from 'common/GroupColorCircle.svelte'
import { VALID_CHOICES } from './common'
import AsyncButton from 'common/AsyncButton.svelte'
let name = $state('')
let description = $state('')
let color = $state<BootstrapThemeColor | ''>('')
let error: string | undefined = $state()
async function save () {
if (!name.trim()) {
error = 'Name is required'
return
}
error = undefined
try {
await api.createTargetGroup({
targetGroupDataRequest: {
name: name.trim(),
description: description.trim() || undefined,
color: color || undefined,
},
})
// Redirect to groups list
replace('/config/target-groups')
} catch (e) {
error = await stringifyError(e)
throw e
}
}
</script>
<div class="container-max-md">
<div class="page-summary-bar">
<h1>add a target group</h1>
</div>
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
<form onsubmit={e => {
e.preventDefault()
save()
}}>
<FormGroup>
<Label for="name">Name</Label>
<Input
id="name"
bind:value={name}
required
/>
</FormGroup>
<FormGroup>
<Label for="description">Description</Label>
<Input
id="description"
type="textarea"
bind:value={description}
/>
</FormGroup>
<FormGroup>
<Label for="color">Color</Label>
<small class="form-text text-muted">
Optional theme color for visual organization
</small>
<div class="color-picker">
{#each VALID_CHOICES as value (value)}
<button
type="button"
class="btn btn-secondary"
class:active={color === value}
onclick={e => {
e.preventDefault()
color = value
}}
title={value || 'None'}
>
<GroupColorCircle color={value} />
<span>{value || 'None'}</span>
</button>
{/each}
</div>
</FormGroup>
<div class="d-flex gap-2 mt-5">
<AsyncButton click={save} color="primary">Create</AsyncButton>
<a class="btn btn-secondary" href="/config/target-groups" use:link>
Cancel
</a>
</div>
</form>
</div>
<style lang="scss">
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
> button {
display: flex;
align-items: center;
gap: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import { api, BootstrapThemeColor, type TargetGroup } from 'admin/lib/api'
import { Button, FormGroup, Input, Label, Alert } from '@sveltestrap/sveltestrap'
import { stringifyError } from 'common/errors'
import { VALID_CHOICES } from './common'
import GroupColorCircle from 'common/GroupColorCircle.svelte'
import AsyncButton from 'common/AsyncButton.svelte'
import Loadable from 'common/Loadable.svelte'
interface Props {
params: { id: string };
}
let { params }: Props = $props()
let groupId = params.id
let group: TargetGroup | undefined = $state()
let error: string | undefined = $state()
let saving = $state(false)
let name = $state('')
let description = $state('')
let color = $state<BootstrapThemeColor | ''>('')
const initPromise = init()
async function init () {
try {
group = await api.getTargetGroup({ id: groupId })
name = group.name
description = group.description
color = group.color ?? ''
} catch (e) {
error = await stringifyError(e)
throw e
}
}
async function update () {
if (!group) {
return
}
saving = true
error = undefined
try {
await api.updateTargetGroup({
id: groupId,
targetGroupDataRequest: {
name,
description: description || undefined,
color: color || undefined,
},
})
} catch (e) {
error = await stringifyError(e)
throw e
} finally {
saving = false
}
}
async function remove () {
if (!group || !confirm(`Delete target group "${group.name}"?`)) {
return
}
try {
await api.deleteTargetGroup({ id: groupId })
// Redirect to groups list
replace('/config/target-groups')
} catch (e) {
error = await stringifyError(e)
throw e
}
}
</script>
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
<Loadable promise={initPromise}>
{#if group}
<div class="container-max-md">
<div class="page-summary-bar">
<div>
<h1>{group.name}</h1>
<div class="text-muted">Target group</div>
</div>
</div>
<form onsubmit={e => {
e.preventDefault()
update()
}}>
<FormGroup>
<Label for="name">Name</Label>
<Input
id="name"
bind:value={name}
required
disabled={saving}
/>
</FormGroup>
<FormGroup>
<Label for="description">Description</Label>
<Input
id="description"
type="textarea"
bind:value={description}
disabled={saving}
/>
</FormGroup>
<FormGroup>
<Label for="color">Color</Label>
<small class="form-text text-muted">
Optional Bootstrap theme color for visual organization
</small>
<div class="color-picker">
{#each VALID_CHOICES as value (value)}
<button
type="button"
class="btn btn-secondary gap-2 d-flex align-items-center"
class:active={color === value}
disabled={saving}
onclick={(e) => {
e.preventDefault()
color = value
}}
title={value || 'None'}
>
<GroupColorCircle color={value} />
<span>{value || 'None'}</span>
</button>
{/each}
</div>
</FormGroup>
<div class="d-flex gap-2 mt-5">
<AsyncButton click={update} color="primary">Update</AsyncButton>
<Button color="danger" onclick={remove}>Remove</Button>
</div>
</form>
</div>
{/if}
</Loadable>
<style lang="scss">
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { Observable, from, map } from 'rxjs'
import { type TargetGroup, api } from 'admin/lib/api'
import ItemList, { type PaginatedResponse } from 'common/ItemList.svelte'
import { link } from 'svelte-spa-router'
import EmptyState from 'common/EmptyState.svelte'
import GroupColorCircle from 'common/GroupColorCircle.svelte'
function getTargetGroups (): Observable<PaginatedResponse<TargetGroup>> {
return from(api.listTargetGroups()).pipe(
map(groups => ({
items: groups,
offset: 0,
total: groups.length,
})),
)
}
</script>
<div class="container-max-md">
<div class="page-summary-bar">
<h1>target groups</h1>
<a
class="btn btn-primary ms-auto"
href="/config/target-groups/create"
use:link>
Add a group
</a>
</div>
<ItemList load={getTargetGroups} showSearch={true}>
{#snippet empty()}
<EmptyState
title="No target groups yet"
hint="Target groups help organize your targets for easier management"
/>
{/snippet}
{#snippet item(group)}
<a
class="list-group-item list-group-item-action"
href="/config/target-groups/{group.id}"
use:link>
<div class="me-auto">
<div class="d-flex align-items-center gap-2">
{#if group.color}
<GroupColorCircle color={group.color} />
{/if}
<strong>{group.name}</strong>
</div>
{#if group.description}
<small class="d-block text-muted">{group.description}</small>
{/if}
</div>
</a>
{/snippet}
</ItemList>
</div>
<style lang="scss">
.list-group-item {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,4 @@
import type { BootstrapThemeColor } from 'gateway/lib/api'
export const VALID_COLORS: BootstrapThemeColor[] = ['Primary', 'Secondary', 'Success', 'Danger', 'Warning', 'Info', 'Light', 'Dark']
export const VALID_CHOICES = ['' as (BootstrapThemeColor | ''), ...VALID_COLORS]

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { api, type Role, type Target } from 'admin/lib/api'
import { api, type Role, type Target, type TargetGroup } from 'admin/lib/api'
import AsyncButton from 'common/AsyncButton.svelte'
import ConnectionInstructions from 'common/ConnectionInstructions.svelte'
import { TargetKind } from 'gateway/lib/api'
@@ -25,9 +25,13 @@
let target: Target | undefined = $state()
let roleIsAllowed: Record<string, any> = $state({})
let connectionsInstructionsModalOpen = $state(false)
let groups: TargetGroup[] = $state([])
async function init () {
target = await api.getTarget({ id: params.id })
[target, groups] = await Promise.all([
api.getTarget({ id: params.id }),
api.listTargetGroups(),
])
}
async function loadRoles () {
@@ -149,9 +153,26 @@
<h4 class="mt-4">Configuration</h4>
<FormGroup floating label="Name">
<Input class="form-control" bind:value={target.name} />
</FormGroup>
<div class="row">
<div class:col-md-8={groups.length > 0} class:col-md-12={!groups.length}>
<FormGroup floating label="Name">
<Input class="form-control" bind:value={target.name} />
</FormGroup>
</div>
{#if groups.length > 0}
<div class="col-md-4">
<FormGroup floating label="Group">
<select class="form-control" bind:value={target.groupId}>
<option value={undefined}>No group</option>
{#each groups as group (group.id)}
<option value={group.id}>{group.name}</option>
{/each}
</select>
</FormGroup>
</div>
{/if}
</div>
<FormGroup floating label="Description">
<Input bind:value={target.description} />
@@ -206,10 +227,10 @@
{#if target.options.kind === 'Postgres'}
<FormGroup floating label="Idle timeout">
<input
class="form-control"
type="text"
placeholder="10m"
<input
class="form-control"
type="text"
placeholder="10m"
bind:value={target.options.idleTimeout}
title="Human-readable duration (e.g., '30m', '1h', '2h30m'). Default: 10m"
/>

View File

@@ -1,33 +1,91 @@
<script lang="ts">
import { Observable, from, map } from 'rxjs'
import { type Target, api } from 'admin/lib/api'
import { type Target, type TargetGroup, api } from 'admin/lib/api'
import ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'
import { link } from 'svelte-spa-router'
import { TargetKind } from 'gateway/lib/api'
import EmptyState from 'common/EmptyState.svelte'
import { onMount } from 'svelte'
import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from '@sveltestrap/sveltestrap'
import GroupColorCircle from 'common/GroupColorCircle.svelte'
import { stringifyError } from 'common/errors'
import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'
import { firstBy } from 'thenby'
let error: string|undefined = $state()
let groups: TargetGroup[] = $state([])
let selectedGroup: TargetGroup|undefined = $state()
onMount(async () => {
try {
groups = await api.listTargetGroups()
} catch (err) {
error = await stringifyError(err)
}
})
function getTargets (options: LoadOptions): Observable<PaginatedResponse<Target>> {
return from(api.getTargets({
search: options.search,
})).pipe(map(targets => ({
items: targets,
offset: 0,
total: targets.length,
})))
groupId: selectedGroup?.id,
})).pipe(
map(targets => targets.sort(
firstBy<Target, boolean>(x => x.options.kind !== TargetKind.WebAdmin)
.thenBy<Target, boolean>(x => !x.groupId)
.thenBy<Target, string | undefined>(
target => groups.find(g => g.id === target.groupId)?.name.toLowerCase())
.thenBy(x => x.name.toLowerCase())
)),
map(targets => ({
items: targets,
offset: 0,
total: targets.length,
})))
}
</script>
<div class="container-max-md">
<div class="page-summary-bar">
<h1>targets</h1>
<a
class="btn btn-primary ms-auto"
href="/config/targets/create"
use:link>
Add a target
</a>
<div class="d-flex gap-2 ms-auto">
{#if groups.length > 0}
<Dropdown>
<DropdownToggle caret>
{selectedGroup?.name ?? 'All groups'}
</DropdownToggle>
<DropdownMenu>
<DropdownItem onclick={() => {
selectedGroup = undefined
}}>
All groups
</DropdownItem>
{#each groups as group (group.id)}
<DropdownItem onclick={() => {
selectedGroup = group
}} class="d-flex align-items-center gap-2">
{#if group.color}
<GroupColorCircle color={group.color} />
{/if}
{group.name}
</DropdownItem>
{/each}
</DropdownMenu>
</Dropdown>
{/if}
<a
class="btn btn-primary"
href="/config/targets/create"
use:link>
Add a target
</a>
</div>
</div>
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
{#key selectedGroup}
<ItemList load={getTargets} showSearch={true}>
{#snippet empty()}
<EmptyState
@@ -42,9 +100,20 @@
href="/config/targets/{target.id}"
use:link>
<div class="me-auto">
<strong>
{target.name}
</strong>
<div class="d-flex align-items-center gap-2">
{#if target.groupId}
{@const group = groups.find(g => g.id === target.groupId)}
{#if group}
{#if group.color}
<GroupColorCircle color={group.color} />
{/if}
<small class="text-muted">{group.name}</small>
{/if}
{/if}
<strong>
{target.name}
</strong>
</div>
{#if target.description}
<small class="d-block text-muted">{target.description}</small>
{/if}
@@ -67,8 +136,9 @@
{/if}
</small>
</a>
{/snippet}
{/snippet}
</ItemList>
{/key}
</div>
<style lang="scss">

View File

@@ -2,7 +2,7 @@
"openapi": "3.0.0",
"info": {
"title": "Warpgate Web Admin",
"version": "v0.17.0-11-g38cbf76-modified"
"version": "v0.17.0-32-g054b9b1a-modified"
},
"servers": [
{
@@ -830,6 +830,17 @@
"required": false,
"deprecated": false,
"explode": true
},
{
"name": "group_id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "query",
"required": false,
"deprecated": false,
"explode": true
}
],
"responses": {
@@ -1221,6 +1232,212 @@
"operationId": "delete_target_role"
}
},
"/target-groups": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json; charset=utf-8": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TargetGroup"
}
}
}
}
}
},
"security": [
{
"TokenSecurityScheme": []
},
{
"CookieSecurityScheme": []
}
],
"operationId": "list_target_groups"
},
"post": {
"requestBody": {
"content": {
"application/json; charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/TargetGroupDataRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "",
"content": {
"application/json; charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/TargetGroup"
}
}
}
},
"409": {
"description": "",
"content": {
"application/json; charset=utf-8": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "",
"content": {
"application/json; charset=utf-8": {
"schema": {
"type": "string"
}
}
}
}
},
"security": [
{
"TokenSecurityScheme": []
},
{
"CookieSecurityScheme": []
}
],
"operationId": "create_target_group"
}
},
"/target-groups/{id}": {
"get": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false,
"explode": true
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json; charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/TargetGroup"
}
}
}
},
"404": {
"description": ""
}
},
"security": [
{
"TokenSecurityScheme": []
},
{
"CookieSecurityScheme": []
}
],
"operationId": "get_target_group"
},
"put": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false,
"explode": true
}
],
"requestBody": {
"content": {
"application/json; charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/TargetGroupDataRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "",
"content": {
"application/json; charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/TargetGroup"
}
}
}
},
"400": {
"description": ""
},
"404": {
"description": ""
}
},
"security": [
{
"TokenSecurityScheme": []
},
{
"CookieSecurityScheme": []
}
],
"operationId": "update_target_group"
},
"delete": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false,
"explode": true
}
],
"responses": {
"204": {
"description": ""
},
"404": {
"description": ""
}
},
"security": [
{
"TokenSecurityScheme": []
},
{
"CookieSecurityScheme": []
}
],
"operationId": "delete_target_group"
}
},
"/users": {
"get": {
"parameters": [
@@ -2330,6 +2547,19 @@
}
}
},
"BootstrapThemeColor": {
"type": "string",
"enum": [
"Primary",
"Secondary",
"Success",
"Danger",
"Warning",
"Info",
"Light",
"Dark"
]
},
"CheckSshHostKeyRequest": {
"type": "object",
"title": "CheckSshHostKeyRequest",
@@ -2937,6 +3167,10 @@
"rate_limit_bytes_per_second": {
"type": "integer",
"format": "uint32"
},
"group_id": {
"type": "string",
"format": "uuid"
}
}
},
@@ -2960,6 +3194,52 @@
"rate_limit_bytes_per_second": {
"type": "integer",
"format": "uint32"
},
"group_id": {
"type": "string",
"format": "uuid"
}
}
},
"TargetGroup": {
"type": "object",
"title": "TargetGroup",
"required": [
"id",
"name",
"description"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"color": {
"$ref": "#/components/schemas/BootstrapThemeColor"
}
}
},
"TargetGroupDataRequest": {
"type": "object",
"title": "TargetGroupDataRequest",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"color": {
"$ref": "#/components/schemas/BootstrapThemeColor"
}
}
},

View File

@@ -52,8 +52,9 @@ async function _click () {
try {
await click()
st = State.Done
} catch {
} catch (e) {
st = State.Failed
throw e
} finally {
setTimeout(() => {
if (st === State.Done || st === State.Failed) {

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { BootstrapThemeColor } from 'gateway/lib/api'
import { getCSSColorFromThemeColor } from './helpers'
export let color: BootstrapThemeColor | ''
</script>
<div class="circle" style="background-color: {getCSSColorFromThemeColor(color || undefined)})"></div>
<style lang="scss">
.circle {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
</style>

View File

@@ -12,24 +12,27 @@
}
</script>
<script lang="ts" generics="T">
<script lang="ts" generics="T, G = unknown, GK = unknown">
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/sveltestrap'
import DelayedSpinner from './DelayedSpinner.svelte'
import { onDestroy, type Snippet } from 'svelte'
import { onDestroy, onMount, type Snippet } from 'svelte'
import EmptyState from './EmptyState.svelte'
interface Props {
page?: number
pageSize?: number|undefined
load: (_: LoadOptions) => Observable<PaginatedResponse<T>>
groupObject?: (_: T) => G
groupKey?: (_: G) => GK
showSearch?: boolean
header?: Snippet<[]>
item?: Snippet<[T]>
footer?: Snippet<[T[]]>
empty?: Snippet<[]>
groupHeader?: Snippet<[G]>
}
let {
@@ -37,10 +40,13 @@
pageSize = undefined,
load,
showSearch = false,
groupObject,
groupKey,
header,
item,
footer,
empty,
groupHeader,
}: Props = $props()
let filter = $state('')
@@ -77,6 +83,12 @@
const total = observe<number>(responses.pipe(map(x => x.total)), 0)
const items = observe<T[]|null>(responses.pipe(map(x => x.items)), null)
onMount(() => {
if (groupHeader && (!groupObject || !groupKey)) {
throw new Error('groupObject and groupKey must be provided when using groupHeader')
}
})
onDestroy(() => {
page$.complete()
filter$.complete()
@@ -106,7 +118,12 @@
</div>
{#if _items}
<div class="list-group list-group-flush mb-3">
{#each _items as _item (_item)}
{#each _items as _item, _index (_item)}
{#if groupHeader}
{#if _index === 0 || groupKey!(groupObject!(_item)) !== groupKey!(groupObject!(_items[_index - 1]!))}
{@render groupHeader(groupObject!(_item))}
{/if}
{/if}
{@render item?.(_item)}
{/each}
</div>

View File

@@ -0,0 +1,7 @@
import { TargetKind, type BootstrapThemeColor, type TargetSnapshot } from 'gateway/lib/api'
export function getCSSColorFromThemeColor(color?: BootstrapThemeColor): string {
// Handle capitalized color names from API (e.g., "Primary" -> "primary")
const colorLower = (color ?? 'Secondary').toLowerCase()
return `var(--bs-${colorLower});`
}

View File

@@ -3,13 +3,14 @@ import { Observable, from, map } from 'rxjs'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import ConnectionInstructions from 'common/ConnectionInstructions.svelte'
import ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'
import { api, type TargetSnapshot, TargetKind } from 'gateway/lib/api'
import { api, type TargetSnapshot, TargetKind, BootstrapThemeColor } from 'gateway/lib/api'
import Fa from 'svelte-fa'
import { Button, Modal, ModalBody, ModalFooter } from '@sveltestrap/sveltestrap'
import { serverInfo } from './lib/store'
import { firstBy } from 'thenby'
import GettingStarted from 'common/GettingStarted.svelte'
import EmptyState from 'common/EmptyState.svelte'
import GroupColorCircle from 'common/GroupColorCircle.svelte'
let selectedTarget: TargetSnapshot|undefined = $state()
@@ -18,6 +19,8 @@ function loadTargets (options: LoadOptions): Observable<PaginatedResponse<Target
map(result => {
result = result.sort(
firstBy<TargetSnapshot, boolean>(x => x.kind !== TargetKind.WebAdmin)
.thenBy<TargetSnapshot, boolean>(x => !x.group)
.thenBy<TargetSnapshot, string | undefined>(x => x.group?.name.toLowerCase())
.thenBy(x => x.name.toLowerCase())
)
return {
@@ -43,6 +46,34 @@ function loadURL (url: string) {
location.href = url
}
interface GroupInfo {
id: string
name: string
color: BootstrapThemeColor
}
function groupInfoFromTarget (target: TargetSnapshot): GroupInfo {
if (target.kind === TargetKind.WebAdmin) {
return {
id: '$admin',
name: 'Administration',
color: BootstrapThemeColor.Danger,
}
}
if (!target.group) {
return {
id: '$ungrouped',
name: 'Ungrouped',
color: BootstrapThemeColor.Secondary,
}
}
return {
id: target.group.id,
name: target.group.name,
color: target.group.color ?? BootstrapThemeColor.Secondary,
}
}
</script>
{#if $serverInfo?.setupState}
@@ -50,11 +81,17 @@ function loadURL (url: string) {
setupState={$serverInfo?.setupState} />
{/if}
<ItemList load={loadTargets} showSearch={true}>
<ItemList load={loadTargets} showSearch={true} groupObject={groupInfoFromTarget} groupKey={group => group.id}>
{#snippet empty()}
<EmptyState
title="You don't have access to any targets yet" />
{/snippet}
{#snippet groupHeader(group)}
<div class="d-flex align-items-center gap-2 mb-2 mt-4">
<GroupColorCircle color={group.color} />
<div class="h5 mb-0">{group.name}</div>
</div>
{/snippet}
{#snippet item(target)}
<a
class="list-group-item list-group-item-action target-item"
@@ -77,7 +114,7 @@ function loadURL (url: string) {
{#if target.kind === TargetKind.WebAdmin}
Manage Warpgate
{:else}
<div>
<div class="d-flex align-items-center gap-2">
{target.name}
</div>
{#if target.description}

View File

@@ -2,7 +2,7 @@
"openapi": "3.0.0",
"info": {
"title": "Warpgate HTTP proxy",
"version": "v0.15.0-11-gd858319-modified"
"version": "v0.17.0-32-g054b9b1a-modified"
},
"servers": [
{
@@ -98,7 +98,7 @@
"description": ""
}
},
"operationId": "getDefaultAuthState"
"operationId": "get_default_auth_state"
},
"delete": {
"responses": {
@@ -116,7 +116,7 @@
"description": ""
}
},
"operationId": "cancelDefaultAuth"
"operationId": "cancel_default_auth"
}
},
"/auth/web-auth-requests": {
@@ -764,6 +764,19 @@
}
}
},
"BootstrapThemeColor": {
"type": "string",
"enum": [
"Primary",
"Secondary",
"Success",
"Danger",
"Warning",
"Info",
"Light",
"Dark"
]
},
"ChangePasswordRequest": {
"type": "object",
"title": "ChangePasswordRequest",
@@ -912,6 +925,26 @@
}
}
},
"GroupInfo": {
"type": "object",
"title": "GroupInfo",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"color": {
"$ref": "#/components/schemas/BootstrapThemeColor"
}
}
},
"Info": {
"type": "object",
"title": "Info",
@@ -1169,6 +1202,9 @@
},
"external_host": {
"type": "string"
},
"group": {
"$ref": "#/components/schemas/GroupInfo"
}
}
},