diff --git a/scnserver/api/handler/apiPreview.go b/scnserver/api/handler/apiPreview.go index c72df9a..e521c49 100644 --- a/scnserver/api/handler/apiPreview.go +++ b/scnserver/api/handler/apiPreview.go @@ -1,14 +1,15 @@ package handler import ( + "database/sql" + "errors" + "net/http" + "blackforestbytes.com/simplecloudnotifier/api/apierr" "blackforestbytes.com/simplecloudnotifier/api/ginresp" "blackforestbytes.com/simplecloudnotifier/logic" "blackforestbytes.com/simplecloudnotifier/models" - "database/sql" - "errors" "git.blackforestbytes.com/BlackForestBytes/goext/ginext" - "net/http" ) // GetUserPreview swaggerdoc @@ -52,7 +53,7 @@ func (h APIHandler) GetUserPreview(pctx ginext.PreContext) ginext.HTTPResponse { return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query user", err) } - return finishSuccess(ginext.JSON(http.StatusOK, user.JSONPreview())) + return finishSuccess(ginext.JSON(http.StatusOK, user.Preview())) }) } @@ -175,3 +176,65 @@ func (h APIHandler) GetUserKeyPreview(pctx ginext.PreContext) ginext.HTTPRespons }) } + +// GetClientPreview swaggerdoc +// +// @Summary Get a client (similar to api-clients-get, but can be called from anyone and only returns a subset of fields) +// @ID api-clients-get-preview +// @Tags API-v2 +// +// @Param cid path string true "ClientID" +// +// @Success 200 {object} handler.GetClientPreview.response +// +// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" +// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" +// @Failure 404 {object} ginresp.apiError "client not found" +// @Failure 500 {object} ginresp.apiError "internal server error" +// +// @Router /api/v2/preview/clients/{cid} [GET] +func (h APIHandler) GetClientPreview(pctx ginext.PreContext) ginext.HTTPResponse { + type uri struct { + ClientID models.ClientID `uri:"cid" binding:"entityid"` + } + type response struct { + Client models.ClientPreview `json:"client"` + User models.UserPreview `json:"user"` + } + + var u uri + ctx, g, errResp := pctx.URI(&u).Start() + if errResp != nil { + return *errResp + } + defer ctx.Cancel() + + return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse { + + if permResp := ctx.CheckPermissionAny(); permResp != nil { + return *permResp + } + + client, err := h.database.GetClientByID(ctx, u.ClientID) + if client == nil { + return ginresp.APIError(g, 404, apierr.CLIENT_NOT_FOUND, "Client not found", err) + } + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query client", err) + } + + user, err := h.database.GetUser(ctx, client.UserID) + if errors.Is(err, sql.ErrNoRows) { + return ginresp.APIError(g, 404, apierr.USER_NOT_FOUND, "User not found", err) + } + if err != nil { + return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query user", err) + } + + return finishSuccess(ginext.JSON(http.StatusOK, response{ + Client: client.Preview(), + User: user.Preview(), + })) + + }) +} diff --git a/scnserver/api/handler/external.go b/scnserver/api/handler/external.go index b0ba600..2cfda1e 100644 --- a/scnserver/api/handler/external.go +++ b/scnserver/api/handler/external.go @@ -148,7 +148,7 @@ func (h ExternalHandler) UptimeKuma(pctx ginext.PreContext) ginext.HTTPResponse // @Tags External // // @Param query_data query handler.Shoutrrr.query false " " -// @Param post_body body handler.Shoutrrr.body false " " +// @Param post_body body handler.Shoutrrr.body false " " // // @Success 200 {object} handler.Shoutrrr.response // @Failure 400 {object} ginresp.apiError diff --git a/scnserver/api/router.go b/scnserver/api/router.go index 32f58e6..e9d2e4f 100644 --- a/scnserver/api/router.go +++ b/scnserver/api/router.go @@ -171,6 +171,7 @@ func (r *Router) Init(e *ginext.GinWrapper) error { apiv2.GET("/preview/users/:uid").Handle(r.apiHandler.GetUserPreview) apiv2.GET("/preview/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview) apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview) + apiv2.GET("/preview/clients/:cid").Handle(r.apiHandler.GetClientPreview) } // ================ Send API (unversioned) ================ diff --git a/scnserver/db/impl/primary/clients.go b/scnserver/db/impl/primary/clients.go index 0e9cd25..028d1ec 100644 --- a/scnserver/db/impl/primary/clients.go +++ b/scnserver/db/impl/primary/clients.go @@ -53,6 +53,15 @@ func (db *Database) GetClient(ctx db.TxContext, userid models.UserID, clientid m }, sq.SModeExtended, sq.Safe) } +func (db *Database) GetClientByID(ctx db.TxContext, clientid models.ClientID) (*models.Client, error) { + tx, err := ctx.GetOrCreateTransaction(db) + if err != nil { + return nil, err + } + + return sq.QuerySingleOpt[models.Client](ctx, tx, "SELECT * FROM clients WHERE deleted=0 AND client_id = :cid LIMIT 1", sq.PP{"cid": clientid}, sq.SModeExtended, sq.Safe) +} + func (db *Database) GetClientOpt(ctx db.TxContext, userid models.UserID, clientid models.ClientID) (*models.Client, error) { tx, err := ctx.GetOrCreateTransaction(db) if err != nil { diff --git a/scnserver/models/client.go b/scnserver/models/client.go index bb3849c..6475572 100644 --- a/scnserver/models/client.go +++ b/scnserver/models/client.go @@ -21,3 +21,25 @@ type Client struct { Name *string `db:"name" json:"name"` Deleted bool `db:"deleted" json:"-"` } + +type ClientPreview struct { + ClientID ClientID `json:"client_id"` + UserID UserID `json:"user_id"` + Type ClientType `json:"type"` + TimestampCreated SCNTime `json:"timestamp_created"` + AgentModel string `json:"agent_model"` + AgentVersion string `json:"agent_version"` + Name *string `json:"name"` +} + +func (c Client) Preview() ClientPreview { + return ClientPreview{ + ClientID: c.ClientID, + UserID: c.UserID, + Type: c.Type, + TimestampCreated: c.TimestampCreated, + AgentModel: c.AgentModel, + AgentVersion: c.AgentVersion, + Name: c.Name, + } +} diff --git a/scnserver/models/user.go b/scnserver/models/user.go index b372d19..1eb6c98 100644 --- a/scnserver/models/user.go +++ b/scnserver/models/user.go @@ -135,7 +135,7 @@ func (u User) MaxTimestampDiffHours() int { return 24 } -func (u User) JSONPreview() UserPreview { +func (u User) Preview() UserPreview { return UserPreview{ UserID: u.UserID, Username: u.Username, diff --git a/webapp/src/app/core/models/client.model.ts b/webapp/src/app/core/models/client.model.ts index d0ab034..817efe8 100644 --- a/webapp/src/app/core/models/client.model.ts +++ b/webapp/src/app/core/models/client.model.ts @@ -1,3 +1,5 @@ +import { UserPreview } from "./user.model"; + export type ClientType = 'ANDROID' | 'IOS' | 'LINUX' | 'MACOS' | 'WINDOWS'; export interface Client { @@ -15,6 +17,19 @@ export interface ClientListResponse { clients: Client[]; } +export interface ClientPreview { + client_id: string; + name: string | null; + type: ClientType; + agent_model: string; + agent_version: string; +} + +export interface ClientPreviewResponse { + user: UserPreview; + client: ClientPreview; +} + export function getClientTypeIcon(type: ClientType): string { switch (type) { case 'ANDROID': diff --git a/webapp/src/app/core/services/api.service.ts b/webapp/src/app/core/services/api.service.ts index b1c9088..2518d65 100644 --- a/webapp/src/app/core/services/api.service.ts +++ b/webapp/src/app/core/services/api.service.ts @@ -26,6 +26,7 @@ import { UpdateKeyRequest, Client, ClientListResponse, + ClientPreviewResponse, SenderNameStatistics, SenderNameListResponse, DeliveryListResponse, @@ -93,6 +94,10 @@ export class ApiService { return this.http.delete(`${this.baseUrl}/users/${userId}/clients/${clientId}`); } + getClientPreview(clientId: string): Observable { + return this.http.get(`${this.baseUrl}/preview/clients/${clientId}`); + } + // Channel endpoints getChannels(userId: string, selector?: ChannelSelector): Observable { let params = new HttpParams(); diff --git a/webapp/src/app/core/services/client-cache.service.ts b/webapp/src/app/core/services/client-cache.service.ts new file mode 100644 index 0000000..41dcfa8 --- /dev/null +++ b/webapp/src/app/core/services/client-cache.service.ts @@ -0,0 +1,44 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, of, catchError, map, shareReplay } from 'rxjs'; +import { ApiService } from './api.service'; + +export interface ResolvedClient { + clientId: string; + clientName: string | null; + userId: string; + userName: string | null; + agentModel: string; + agentVersion: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class ClientCacheService { + private apiService = inject(ApiService); + + private cache = new Map>(); + + resolveClient(clientId: string): Observable { + if (!this.cache.has(clientId)) { + const request$ = this.apiService.getClientPreview(clientId).pipe( + map(response => ({ + clientId: response.client.client_id, + clientName: response.client.name, + userId: response.user.user_id, + userName: response.user.username, + agentModel: response.client.agent_model, + agentVersion: response.client.agent_version, + })), + catchError(() => of(null)), + shareReplay(1) + ); + this.cache.set(clientId, request$); + } + return this.cache.get(clientId)!; + } + + clearCache(): void { + this.cache.clear(); + } +} diff --git a/webapp/src/app/features/messages/message-detail/message-detail.component.html b/webapp/src/app/features/messages/message-detail/message-detail.component.html index 13c0efc..a10e95e 100644 --- a/webapp/src/app/features/messages/message-detail/message-detail.component.html +++ b/webapp/src/app/features/messages/message-detail/message-detail.component.html @@ -90,18 +90,33 @@ > - Client ID + Client + User + Agent Status Retries Created Finalized + FCM-ID @for (delivery of deliveriesTable.data; track delivery.delivery_id) { - {{ delivery.receiver_client_id }} +
{{ getResolvedClient(delivery.receiver_client_id)?.clientName || '-' }}
+
{{ delivery.receiver_client_id }}
+ + +
{{ getResolvedClient(delivery.receiver_client_id)?.userName || '-' }}
+
{{ delivery.receiver_user_id }}
+ + + @if (getResolvedClient(delivery.receiver_client_id); as client) { + {{ client.agentModel }} {{ client.agentVersion }} + } @else { + - + } @@ -111,6 +126,20 @@ {{ delivery.retry_count }} {{ delivery.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }} {{ delivery.timestamp_finalized ? (delivery.timestamp_finalized | date:'yyyy-MM-dd HH:mm:ss') : '-' }} + + @if (delivery.fcm_message_id) { + + } @else { + - + } + } diff --git a/webapp/src/app/features/messages/message-detail/message-detail.component.ts b/webapp/src/app/features/messages/message-detail/message-detail.component.ts index a42b770..de7e113 100644 --- a/webapp/src/app/features/messages/message-detail/message-detail.component.ts +++ b/webapp/src/app/features/messages/message-detail/message-detail.component.ts @@ -8,13 +8,16 @@ import { NzTagModule } from 'ng-zorro-antd/tag'; import { NzSpinModule } from 'ng-zorro-antd/spin'; import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm'; import { NzTableModule } from 'ng-zorro-antd/table'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { ApiService } from '../../../core/services/api.service'; import { AuthService } from '../../../core/services/auth.service'; import { NotificationService } from '../../../core/services/notification.service'; import { SettingsService } from '../../../core/services/settings.service'; import { KeyCacheService, ResolvedKey } from '../../../core/services/key-cache.service'; import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; +import { ClientCacheService, ResolvedClient } from '../../../core/services/client-cache.service'; import { Message, Delivery } from '../../../core/models'; +import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; @@ -31,10 +34,12 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c NzSpinModule, NzPopconfirmModule, NzTableModule, + NzToolTipModule, RouterLink, RelativeTimePipe, MetadataGridComponent, MetadataValueComponent, + CopyToClipboardDirective, ], templateUrl: './message-detail.component.html', styleUrl: './message-detail.component.scss' @@ -48,11 +53,13 @@ export class MessageDetailComponent implements OnInit { private settingsService = inject(SettingsService); private keyCacheService = inject(KeyCacheService); private userCacheService = inject(UserCacheService); + private clientCacheService = inject(ClientCacheService); message = signal(null); resolvedKey = signal(null); resolvedChannelOwner = signal(null); deliveries = signal([]); + resolvedClients = signal>(new Map()); loading = signal(true); deleting = signal(false); loadingDeliveries = signal(false); @@ -107,6 +114,7 @@ export class MessageDetailComponent implements OnInit { next: (response) => { this.deliveries.set(response.deliveries); this.loadingDeliveries.set(false); + this.resolveDeliveryClients(response.deliveries); }, error: () => { this.loadingDeliveries.set(false); @@ -114,6 +122,27 @@ export class MessageDetailComponent implements OnInit { }); } + private resolveDeliveryClients(deliveries: Delivery[]): void { + const uniqueClientIds = [...new Set(deliveries.map(d => d.receiver_client_id))]; + for (const clientId of uniqueClientIds) { + this.clientCacheService.resolveClient(clientId).subscribe({ + next: (resolved) => { + if (resolved) { + this.resolvedClients.update(map => { + const newMap = new Map(map); + newMap.set(clientId, resolved); + return newMap; + }); + } + } + }); + } + } + + getResolvedClient(clientId: string): ResolvedClient | undefined { + return this.resolvedClients().get(clientId); + } + private resolveKey(keyId: string): void { this.keyCacheService.resolveKey(keyId).subscribe({ next: (resolved) => this.resolvedKey.set(resolved)