diff --git a/warpgate-admin/src/api/mod.rs b/warpgate-admin/src/api/mod.rs index 5b46491f..afffc60b 100644 --- a/warpgate-admin/src/api/mod.rs +++ b/warpgate-admin/src/api/mod.rs @@ -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, diff --git a/warpgate-admin/src/api/target_groups.rs b/warpgate-admin/src/api/target_groups.rs new file mode 100644 index 00000000..c26b3fbe --- /dev/null +++ b/warpgate-admin/src/api/target_groups.rs @@ -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, + color: Option, +} + +#[derive(ApiResponse)] +enum GetTargetGroupsResponse { + #[oai(status = 200)] + Ok(Json>), +} + +#[derive(ApiResponse)] +enum CreateTargetGroupResponse { + #[oai(status = 201)] + Created(Json), + + #[oai(status = 409)] + Conflict(Json), + + #[oai(status = 400)] + BadRequest(Json), +} + +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>>, + _sec_scheme: AnySecurityScheme, + ) -> Result { + 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>>, + body: Json, + _sec_scheme: AnySecurityScheme, + ) -> Result { + 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), + #[oai(status = 404)] + NotFound, +} + +#[derive(ApiResponse)] +enum UpdateTargetGroupResponse { + #[oai(status = 200)] + Ok(Json), + #[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>>, + id: Path, + _sec_scheme: AnySecurityScheme, + ) -> Result { + 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>>, + id: Path, + body: Json, + _sec_scheme: AnySecurityScheme, + ) -> Result { + 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>>, + id: Path, + _sec_scheme: AnySecurityScheme, + ) -> Result { + 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::::None)) + .filter(Target::Column::GroupId.eq(id.0)) + .exec(&*db) + .await?; + + // Then delete the group + group.delete(&*db).await?; + Ok(DeleteTargetGroupResponse::Deleted) + } +} diff --git a/warpgate-admin/src/api/targets.rs b/warpgate-admin/src/api/targets.rs index da9b933f..dd0d789f 100644 --- a/warpgate-admin/src/api/targets.rs +++ b/warpgate-admin/src/api/targets.rs @@ -26,6 +26,7 @@ struct TargetDataRequest { description: Option, options: TargetOptions, rate_limit_bytes_per_second: Option, + group_id: Option, } #[derive(ApiResponse)] @@ -55,6 +56,7 @@ impl ListApi { &self, db: Data<&Arc>>, search: Query>, + group_id: Query>, _sec_scheme: AnySecurityScheme, ) -> Result { 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, _> = @@ -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); diff --git a/warpgate-common/src/config/target.rs b/warpgate-common/src/config/target.rs index 59092e4b..fde0b235 100644 --- a/warpgate-common/src/config/target.rs +++ b/warpgate-common/src/config/target.rs @@ -142,6 +142,7 @@ pub struct Target { #[serde(flatten)] pub options: TargetOptions, pub rate_limit_bytes_per_second: Option, + pub group_id: Option, } #[derive(Debug, Deserialize, Serialize, Clone, Union)] diff --git a/warpgate-core/src/db/mod.rs b/warpgate-core/src/db/mod.rs index f9bcbb87..61fb106b 100644 --- a/warpgate-core/src/db/mod.rs +++ b/warpgate-core/src/db/mod.rs @@ -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)? diff --git a/warpgate-db-entities/src/Target.rs b/warpgate-db-entities/src/Target.rs index fc0e557e..36fa8e8e 100644 --- a/warpgate-db-entities/src/Target.rs +++ b/warpgate-db-entities/src/Target.rs @@ -52,6 +52,7 @@ pub struct Model { pub kind: TargetKind, pub options: serde_json::Value, pub rate_limit_bytes_per_second: Option, + pub group_id: Option, } impl Related for Entity { @@ -65,7 +66,17 @@ impl Related 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 for Entity { + fn to() -> RelationDef { + Relation::TargetGroup.def() + } +} impl ActiveModelBehavior for ActiveModel {} @@ -81,6 +92,7 @@ impl TryFrom 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, }) } } diff --git a/warpgate-db-entities/src/TargetGroup.rs b/warpgate-db-entities/src/TargetGroup.rs new file mode 100644 index 00000000..9f96e6cd --- /dev/null +++ b/warpgate-db-entities/src/TargetGroup.rs @@ -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, // 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 {} diff --git a/warpgate-db-entities/src/lib.rs b/warpgate-db-entities/src/lib.rs index b0ca9ce2..19b10434 100644 --- a/warpgate-db-entities/src/lib.rs +++ b/warpgate-db-entities/src/lib.rs @@ -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; diff --git a/warpgate-db-migrations/src/lib.rs b/warpgate-db-migrations/src/lib.rs index 84cf231b..45996d64 100644 --- a/warpgate-db-migrations/src/lib.rs +++ b/warpgate-db-migrations/src/lib.rs @@ -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), ] } } diff --git a/warpgate-db-migrations/src/m00020_target_groups.rs b/warpgate-db-migrations/src/m00020_target_groups.rs new file mode 100644 index 00000000..00dab423 --- /dev/null +++ b/warpgate-db-migrations/src/m00020_target_groups.rs @@ -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, + } + + #[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(()) + } +} diff --git a/warpgate-protocol-http/src/api/targets_list.rs b/warpgate-protocol-http/src/api/targets_list.rs index 3f3e0b7c..2ef35444 100644 --- a/warpgate-protocol-http/src/api/targets_list.rs +++ b/warpgate-protocol-http/src/api/targets_list.rs @@ -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, +} + #[derive(Debug, Serialize, Clone, Object)] pub struct TargetSnapshot { pub name: String, pub description: String, pub kind: Target::TargetKind, pub external_host: Option, + pub group: Option, } #[derive(ApiResponse)] @@ -41,7 +53,16 @@ impl Api { auth: Data<&RequestAuthorization>, search: Query>, _sec_scheme: AnySecurityScheme, - ) -> poem::Result { + ) -> Result { + // Fetch target groups for group information + let groups: Vec = { + let db = services.db.lock().await; + TargetGroup::Entity::find().all(&*db).await + }?; + + let group_map: HashMap = + 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::>() .await; - targets.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(GetTargetsResponse::Ok(Json( - targets - .into_iter() - .map(|t| TargetSnapshot { + let result: Vec = 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))) } } diff --git a/warpgate-web/src/admin/config/Config.svelte b/warpgate-web/src/admin/config/Config.svelte index a3b4a911..dcf71fea 100644 --- a/warpgate-web/src/admin/config/Config.svelte +++ b/warpgate-web/src/admin/config/Config.svelte @@ -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} /> + + - 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 @@ + {#if groups.length > 0} + + + + {/if} + + {/each} + + + +
+ Create + + Cancel + +
+ + + + diff --git a/warpgate-web/src/admin/config/target-groups/TargetGroup.svelte b/warpgate-web/src/admin/config/target-groups/TargetGroup.svelte new file mode 100644 index 00000000..6a5dd69f --- /dev/null +++ b/warpgate-web/src/admin/config/target-groups/TargetGroup.svelte @@ -0,0 +1,158 @@ + + + +{#if error} + {error} +{/if} + +{#if group} +
+
+
+

{group.name}

+
Target group
+
+
+ +
{ + e.preventDefault() + update() + }}> + + + + + + + + + + + + + + Optional Bootstrap theme color for visual organization + +
+ {#each VALID_CHOICES as value (value)} + + {/each} +
+
+ +
+ Update + +
+
+
+{/if} +
+ + diff --git a/warpgate-web/src/admin/config/target-groups/TargetGroups.svelte b/warpgate-web/src/admin/config/target-groups/TargetGroups.svelte new file mode 100644 index 00000000..890845c3 --- /dev/null +++ b/warpgate-web/src/admin/config/target-groups/TargetGroups.svelte @@ -0,0 +1,64 @@ + + +
+
+

target groups

+ + Add a group + +
+ + + {#snippet empty()} + + {/snippet} + {#snippet item(group)} + +
+
+ {#if group.color} + + {/if} + {group.name} +
+ {#if group.description} + {group.description} + {/if} +
+
+ {/snippet} +
+
+ + diff --git a/warpgate-web/src/admin/config/target-groups/common.ts b/warpgate-web/src/admin/config/target-groups/common.ts new file mode 100644 index 00000000..ddfee332 --- /dev/null +++ b/warpgate-web/src/admin/config/target-groups/common.ts @@ -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] diff --git a/warpgate-web/src/admin/config/targets/Target.svelte b/warpgate-web/src/admin/config/targets/Target.svelte index 800586cb..c238e1a6 100644 --- a/warpgate-web/src/admin/config/targets/Target.svelte +++ b/warpgate-web/src/admin/config/targets/Target.svelte @@ -1,5 +1,5 @@

targets

- - Add a target - +
+ {#if groups.length > 0} + + + {selectedGroup?.name ?? 'All groups'} + + + { + selectedGroup = undefined + }}> + All groups + + {#each groups as group (group.id)} + { + selectedGroup = group + }} class="d-flex align-items-center gap-2"> + {#if group.color} + + {/if} + {group.name} + + {/each} + + + {/if} + + Add a target + +
+ {#if error} + {error} + {/if} + + {#key selectedGroup} {#snippet empty()}
- - {target.name} - +
+ {#if target.groupId} + {@const group = groups.find(g => g.id === target.groupId)} + {#if group} + {#if group.color} + + {/if} + {group.name} + {/if} + {/if} + + {target.name} + +
{#if target.description} {target.description} {/if} @@ -67,8 +136,9 @@ {/if} - {/snippet} + {/snippet} + {/key}
diff --git a/warpgate-web/src/common/ItemList.svelte b/warpgate-web/src/common/ItemList.svelte index e008ffec..0ce488aa 100644 --- a/warpgate-web/src/common/ItemList.svelte +++ b/warpgate-web/src/common/ItemList.svelte @@ -12,24 +12,27 @@ } - {#if $serverInfo?.setupState} @@ -50,11 +81,17 @@ function loadURL (url: string) { setupState={$serverInfo?.setupState} /> {/if} - + group.id}> {#snippet empty()} {/snippet} + {#snippet groupHeader(group)} +
+ +
{group.name}
+
+ {/snippet} {#snippet item(target)} +
{target.name}
{#if target.description} diff --git a/warpgate-web/src/gateway/lib/openapi-schema.json b/warpgate-web/src/gateway/lib/openapi-schema.json index f5ad16d9..047b9647 100644 --- a/warpgate-web/src/gateway/lib/openapi-schema.json +++ b/warpgate-web/src/gateway/lib/openapi-schema.json @@ -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" } } },