mirror of
https://github.com/warp-tech/warpgate.git
synced 2026-01-06 06:50:08 -06:00
Add target groups for improved interface (#1518)
Co-authored-by: Eugene <inbox@null.page>
This commit is contained in:
@@ -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,
|
||||
|
||||
230
warpgate-admin/src/api/target_groups.rs
Normal file
230
warpgate-admin/src/api/target_groups.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
46
warpgate-db-entities/src/TargetGroup.rs
Normal file
46
warpgate-db-entities/src/TargetGroup.rs
Normal 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 {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
104
warpgate-db-migrations/src/m00020_target_groups.rs
Normal file
104
warpgate-db-migrations/src/m00020_target_groups.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
158
warpgate-web/src/admin/config/target-groups/TargetGroup.svelte
Normal file
158
warpgate-web/src/admin/config/target-groups/TargetGroup.svelte
Normal 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>
|
||||
@@ -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>
|
||||
4
warpgate-web/src/admin/config/target-groups/common.ts
Normal file
4
warpgate-web/src/admin/config/target-groups/common.ts
Normal 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]
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
18
warpgate-web/src/common/GroupColorCircle.svelte
Normal file
18
warpgate-web/src/common/GroupColorCircle.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
7
warpgate-web/src/common/helpers.ts
Normal file
7
warpgate-web/src/common/helpers.ts
Normal 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});`
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user