From 6090319b5f5d8bea678dacfa1406a92cf8ac13fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Wed, 3 Dec 2025 19:38:15 +0100 Subject: [PATCH] Simple Managment webapp [LLM] --- webapp/src/app/core/models/message.model.ts | 6 +- webapp/src/app/core/services/api.service.ts | 18 ++- .../core/services/channel-cache.service.ts | 88 +++++++++++ .../features/auth/login/login.component.html | 2 +- .../keys/key-list/key-list.component.html | 143 ++++++++++++++++-- .../keys/key-list/key-list.component.scss | 5 + .../keys/key-list/key-list.component.ts | 111 +++++++++++++- .../message-list/message-list.component.html | 35 ++++- .../message-list/message-list.component.scss | 5 + .../message-list/message-list.component.ts | 86 ++++++++++- 10 files changed, 468 insertions(+), 31 deletions(-) create mode 100644 webapp/src/app/core/services/channel-cache.service.ts diff --git a/webapp/src/app/core/models/message.model.ts b/webapp/src/app/core/models/message.model.ts index 78ab067..38669ff 100644 --- a/webapp/src/app/core/models/message.model.ts +++ b/webapp/src/app/core/models/message.model.ts @@ -18,10 +18,10 @@ export interface Message { export interface MessageListParams { after?: string; before?: string; - channel?: string; - priority?: number; + channel_id?: string[]; + priority?: number[]; search?: string; - sender?: string; + sender?: string[]; subscription_status?: 'all' | 'confirmed' | 'unconfirmed'; trimmed?: boolean; page_size?: number; diff --git a/webapp/src/app/core/services/api.service.ts b/webapp/src/app/core/services/api.service.ts index dea2fb5..8c0c695 100644 --- a/webapp/src/app/core/services/api.service.ts +++ b/webapp/src/app/core/services/api.service.ts @@ -135,10 +135,22 @@ export class ApiService { if (params) { if (params.after) httpParams = httpParams.set('after', params.after); if (params.before) httpParams = httpParams.set('before', params.before); - if (params.channel) httpParams = httpParams.set('channel', params.channel); - if (params.priority !== undefined) httpParams = httpParams.set('priority', params.priority); + if (params.channel_id) { + for (const c of params.channel_id) { + httpParams = httpParams.append('channel_id', c); + } + } + if (params.priority) { + for (const p of params.priority) { + httpParams = httpParams.append('priority', p); + } + } if (params.search) httpParams = httpParams.set('search', params.search); - if (params.sender) httpParams = httpParams.set('sender', params.sender); + if (params.sender) { + for (const s of params.sender) { + httpParams = httpParams.append('sender', s); + } + } if (params.subscription_status) httpParams = httpParams.set('subscription_status', params.subscription_status); if (params.trimmed !== undefined) httpParams = httpParams.set('trimmed', params.trimmed); if (params.page_size) httpParams = httpParams.set('page_size', params.page_size); diff --git a/webapp/src/app/core/services/channel-cache.service.ts b/webapp/src/app/core/services/channel-cache.service.ts new file mode 100644 index 0000000..afdba3e --- /dev/null +++ b/webapp/src/app/core/services/channel-cache.service.ts @@ -0,0 +1,88 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, of, map, shareReplay, catchError } from 'rxjs'; +import { ApiService } from './api.service'; +import { AuthService } from './auth.service'; +import { ChannelWithSubscription } from '../models'; + +export interface ResolvedChannel { + channelId: string; + displayName: string; + internalName: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class ChannelCacheService { + private apiService = inject(ApiService); + private authService = inject(AuthService); + + private channelsCache$: Observable> | null = null; + + getAllChannels(): Observable { + const userId = this.authService.getUserId(); + if (!userId) { + return of([]); + } + return this.apiService.getChannels(userId, 'owned').pipe( + map(response => response.channels), + catchError(() => of([])) + ); + } + + resolveChannel(channelId: string): Observable { + return this.getChannelsMap().pipe( + map(channelsMap => { + const channel = channelsMap.get(channelId); + return { + channelId, + displayName: channel?.display_name || channel?.internal_name || channelId, + internalName: channel?.internal_name || channelId + }; + }) + ); + } + + resolveChannels(channelIds: string[]): Observable> { + return this.getChannelsMap().pipe( + map(channelsMap => { + const resolved = new Map(); + for (const channelId of channelIds) { + const channel = channelsMap.get(channelId); + resolved.set(channelId, { + channelId, + displayName: channel?.display_name || channel?.internal_name || channelId, + internalName: channel?.internal_name || channelId + }); + } + return resolved; + }) + ); + } + + private getChannelsMap(): Observable> { + const userId = this.authService.getUserId(); + if (!userId) { + return of(new Map()); + } + + if (!this.channelsCache$) { + this.channelsCache$ = this.apiService.getChannels(userId, 'owned').pipe( + map(response => { + const map = new Map(); + for (const channel of response.channels) { + map.set(channel.channel_id, channel); + } + return map; + }), + catchError(() => of(new Map())), + shareReplay(1) + ); + } + return this.channelsCache$; + } + + clearCache(): void { + this.channelsCache$ = null; + } +} diff --git a/webapp/src/app/features/auth/login/login.component.html b/webapp/src/app/features/auth/login/login.component.html index b2c73a3..6f5a3c0 100644 --- a/webapp/src/app/features/auth/login/login.component.html +++ b/webapp/src/app/features/auth/login/login.component.html @@ -69,7 +69,7 @@ diff --git a/webapp/src/app/features/keys/key-list/key-list.component.html b/webapp/src/app/features/keys/key-list/key-list.component.html index acdcd82..db5d52f 100644 --- a/webapp/src/app/features/keys/key-list/key-list.component.html +++ b/webapp/src/app/features/keys/key-list/key-list.component.html @@ -59,6 +59,12 @@ All Channels + } @else if (key.channels && key.channels.length > 0) { + @for (channelId of key.channels; track channelId) { + + {{ getChannelDisplayName(channelId) }} + + } } @@ -73,22 +79,29 @@ } - @if (!isCurrentKey(key)) { +
- } @else { - - - - - } + @if (!isCurrentKey(key)) { + + } +
} @empty { @@ -180,11 +193,33 @@ - + + + @if (!newKeyAllChannels) { + + Channels + + + @for (channel of availableChannels(); track channel.channel_id) { + + } + + + + } } @@ -205,3 +240,87 @@ } + + + + + + Name + + + + + + + Permissions + +
+ @for (opt of permissionOptions; track opt.value) { + + } +
+
+
+ + + + + + @if (!editKeyAllChannels) { + + Channels + + + @for (channel of availableChannels(); track channel.channel_id) { + + } + + + + } +
+
+ + + + + diff --git a/webapp/src/app/features/keys/key-list/key-list.component.scss b/webapp/src/app/features/keys/key-list/key-list.component.scss index 3de064b..6315804 100644 --- a/webapp/src/app/features/keys/key-list/key-list.component.scss +++ b/webapp/src/app/features/keys/key-list/key-list.component.scss @@ -42,6 +42,11 @@ color: #999; } +.action-buttons { + display: flex; + gap: 8px; +} + .copy-icon { cursor: pointer; color: #999; diff --git a/webapp/src/app/features/keys/key-list/key-list.component.ts b/webapp/src/app/features/keys/key-list/key-list.component.ts index 3378f54..aef3243 100644 --- a/webapp/src/app/features/keys/key-list/key-list.component.ts +++ b/webapp/src/app/features/keys/key-list/key-list.component.ts @@ -14,10 +14,12 @@ import { NzInputModule } from 'ng-zorro-antd/input'; import { NzCheckboxModule } from 'ng-zorro-antd/checkbox'; import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { NzAlertModule } from 'ng-zorro-antd/alert'; +import { NzSelectModule } from 'ng-zorro-antd/select'; import { ApiService } from '../../../core/services/api.service'; import { AuthService } from '../../../core/services/auth.service'; import { NotificationService } from '../../../core/services/notification.service'; -import { KeyToken, parsePermissions, TokenPermission } from '../../../core/models'; +import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service'; +import { KeyToken, parsePermissions, TokenPermission, ChannelWithSubscription } from '../../../core/models'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive'; @@ -46,6 +48,7 @@ interface PermissionOption { NzCheckboxModule, NzToolTipModule, NzAlertModule, + NzSelectModule, RelativeTimePipe, CopyToClipboardDirective, ], @@ -56,19 +59,32 @@ export class KeyListComponent implements OnInit { private apiService = inject(ApiService); private authService = inject(AuthService); private notification = inject(NotificationService); + private channelCacheService = inject(ChannelCacheService); keys = signal([]); currentKeyId = signal(null); loading = signal(false); + channelNames = signal>(new Map()); + availableChannels = signal([]); // Create modal showCreateModal = signal(false); newKeyName = ''; newKeyPermissions: TokenPermission[] = ['CR']; newKeyAllChannels = true; + newKeyChannels: string[] = []; creating = signal(false); createdKey = signal(null); + // Edit modal + showEditModal = signal(false); + editingKey = signal(null); + editKeyName = ''; + editKeyPermissions: TokenPermission[] = []; + editKeyAllChannels = true; + editKeyChannels: string[] = []; + updating = signal(false); + permissionOptions: PermissionOption[] = [ { value: 'A', label: 'Admin', description: 'Full access to all operations' }, { value: 'CR', label: 'Channel Read', description: 'Read messages from channels' }, @@ -79,6 +95,17 @@ export class KeyListComponent implements OnInit { ngOnInit(): void { this.loadKeys(); this.loadCurrentKey(); + this.loadAvailableChannels(); + } + + loadAvailableChannels(): void { + this.channelCacheService.getAllChannels().subscribe(channels => { + this.availableChannels.set(channels); + }); + } + + getChannelLabel(channel: ChannelWithSubscription): string { + return channel.display_name || channel.internal_name; } loadKeys(): void { @@ -90,6 +117,7 @@ export class KeyListComponent implements OnInit { next: (response) => { this.keys.set(response.keys); this.loading.set(false); + this.resolveChannelNames(response.keys); }, error: () => { this.loading.set(false); @@ -97,6 +125,28 @@ export class KeyListComponent implements OnInit { }); } + private resolveChannelNames(keys: KeyToken[]): void { + const allChannelIds = new Set(); + for (const key of keys) { + if (!key.all_channels && key.channels) { + for (const channelId of key.channels) { + allChannelIds.add(channelId); + } + } + } + + if (allChannelIds.size > 0) { + this.channelCacheService.resolveChannels([...allChannelIds]).subscribe(resolved => { + this.channelNames.set(resolved); + }); + } + } + + getChannelDisplayName(channelId: string): string { + const resolved = this.channelNames().get(channelId); + return resolved?.displayName || channelId; + } + loadCurrentKey(): void { const userId = this.authService.getUserId(); if (!userId) return; @@ -134,6 +184,7 @@ export class KeyListComponent implements OnInit { this.newKeyName = ''; this.newKeyPermissions = ['CR']; this.newKeyAllChannels = true; + this.newKeyChannels = []; this.createdKey.set(null); this.showCreateModal.set(true); } @@ -150,7 +201,8 @@ export class KeyListComponent implements OnInit { this.apiService.createKey(userId, { name: this.newKeyName.trim(), permissions: this.newKeyPermissions.join(';'), - all_channels: this.newKeyAllChannels + all_channels: this.newKeyAllChannels, + channels: this.newKeyAllChannels ? undefined : this.newKeyChannels }).subscribe({ next: (key) => { this.createdKey.set(key); @@ -198,4 +250,59 @@ export class KeyListComponent implements OnInit { isPermissionChecked(perm: TokenPermission): boolean { return this.newKeyPermissions.includes(perm); } + + // Edit key modal + openEditModal(key: KeyToken): void { + this.editingKey.set(key); + this.editKeyName = key.name; + this.editKeyPermissions = parsePermissions(key.permissions); + this.editKeyAllChannels = key.all_channels; + this.editKeyChannels = key.channels ? [...key.channels] : []; + this.showEditModal.set(true); + } + + closeEditModal(): void { + this.showEditModal.set(false); + this.editingKey.set(null); + } + + updateKey(): void { + const userId = this.authService.getUserId(); + const key = this.editingKey(); + if (!userId || !key || !this.editKeyName.trim() || this.editKeyPermissions.length === 0) return; + + this.updating.set(true); + this.apiService.updateKey(userId, key.keytoken_id, { + name: this.editKeyName.trim(), + permissions: this.editKeyPermissions.join(';'), + all_channels: this.editKeyAllChannels, + channels: this.editKeyAllChannels ? undefined : this.editKeyChannels + }).subscribe({ + next: () => { + this.notification.success('Key updated'); + this.updating.set(false); + this.closeEditModal(); + this.loadKeys(); + }, + error: () => { + this.updating.set(false); + } + }); + } + + onEditPermissionChange(perm: TokenPermission, checked: boolean): void { + if (checked) { + if (perm === 'A') { + this.editKeyPermissions = ['A']; + } else if (!this.editKeyPermissions.includes(perm)) { + this.editKeyPermissions = [...this.editKeyPermissions, perm]; + } + } else { + this.editKeyPermissions = this.editKeyPermissions.filter(p => p !== perm); + } + } + + isEditPermissionChecked(perm: TokenPermission): boolean { + return this.editKeyPermissions.includes(perm); + } } diff --git a/webapp/src/app/features/messages/message-list/message-list.component.html b/webapp/src/app/features/messages/message-list/message-list.component.html index 48dcd05..07ef5e5 100644 --- a/webapp/src/app/features/messages/message-list/message-list.component.html +++ b/webapp/src/app/features/messages/message-list/message-list.component.html @@ -36,11 +36,21 @@ {{ getChannelDisplayName(channel) }} } + @for (sender of senderFilter; track sender) { + + {{ sender }} + + } @if (priorityFilter.length > 0) { {{ getPriorityLabel(+priorityFilter[0]) }} } + @if (dateRange) { + + {{ getDateRangeDisplay() }} + + } Clear all } @@ -64,14 +74,35 @@ [nzFilterMultiple]="true" (nzFilterChange)="onChannelFilterChange($event)" >Channel - Sender + Sender Priority - Time + + Time + + + + +
+ +
+
+ diff --git a/webapp/src/app/features/messages/message-list/message-list.component.scss b/webapp/src/app/features/messages/message-list/message-list.component.scss index f41ad08..21e0a80 100644 --- a/webapp/src/app/features/messages/message-list/message-list.component.scss +++ b/webapp/src/app/features/messages/message-list/message-list.component.scss @@ -13,6 +13,11 @@ margin-bottom: 16px; } +.date-filter-dropdown { + padding: 12px; + background: #fff; +} + .active-filters { display: flex; align-items: center; diff --git a/webapp/src/app/features/messages/message-list/message-list.component.ts b/webapp/src/app/features/messages/message-list/message-list.component.ts index d31fee2..323fa5e 100644 --- a/webapp/src/app/features/messages/message-list/message-list.component.ts +++ b/webapp/src/app/features/messages/message-list/message-list.component.ts @@ -11,6 +11,8 @@ import { NzEmptyModule } from 'ng-zorro-antd/empty'; import { NzSpinModule } from 'ng-zorro-antd/spin'; import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { NzPaginationModule } from 'ng-zorro-antd/pagination'; +import { NzDatePickerModule } from 'ng-zorro-antd/date-picker'; +import { NzDropDownModule } from 'ng-zorro-antd/dropdown'; import { ApiService } from '../../../core/services/api.service'; import { AuthService } from '../../../core/services/auth.service'; import { Message, MessageListParams } from '../../../core/models'; @@ -31,6 +33,8 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; NzSpinModule, NzToolTipModule, NzPaginationModule, + NzDatePickerModule, + NzDropDownModule, RelativeTimePipe, ], templateUrl: './message-list.component.html', @@ -54,6 +58,9 @@ export class MessageListComponent implements OnInit { appliedSearchText = ''; priorityFilter: string[] = []; channelFilter: string[] = []; + senderFilter: string[] = []; + dateRange: [Date, Date] | null = null; + dateFilterVisible = false; // Filter options priorityFilters: NzTableFilterList = [ @@ -62,9 +69,11 @@ export class MessageListComponent implements OnInit { { text: 'High', value: '2' }, ]; channelFilters = signal([]); + senderFilters = signal([]); ngOnInit(): void { this.loadChannels(); + this.loadSenders(); this.loadMessages(); } @@ -77,7 +86,20 @@ export class MessageListComponent implements OnInit { this.channelFilters.set( response.channels.map(ch => ({ text: ch.display_name, - value: ch.internal_name, + value: ch.channel_id, + })) + ); + } + }); + } + + loadSenders(): void { + this.apiService.getSenderNames().subscribe({ + next: (response) => { + this.senderFilters.set( + response.sender_names.map(s => ({ + text: s.name, + value: s.name, })) ); } @@ -95,11 +117,18 @@ export class MessageListComponent implements OnInit { if (this.appliedSearchText) { params.search = this.appliedSearchText; } - if (this.priorityFilter.length === 1) { - params.priority = parseInt(this.priorityFilter[0], 10); + if (this.priorityFilter.length > 0) { + params.priority = this.priorityFilter.map(p => parseInt(p, 10)); } if (this.channelFilter.length > 0) { - params.channel = this.channelFilter.join(','); + params.channel_id = this.channelFilter; + } + if (this.senderFilter.length > 0) { + params.sender = this.senderFilter; + } + if (this.dateRange) { + params.after = this.dateRange[0].toISOString(); + params.before = this.dateRange[1].toISOString(); } // Use page-index based pagination: $1 = page 1, $2 = page 2, etc. @@ -138,6 +167,12 @@ export class MessageListComponent implements OnInit { this.loadMessages(); } + onSenderFilterChange(filters: string[] | null): void { + this.senderFilter = filters ?? []; + this.currentPage.set(1); + this.loadMessages(); + } + clearSearch(): void { this.searchText = ''; this.appliedSearchText = ''; @@ -157,29 +192,64 @@ export class MessageListComponent implements OnInit { this.loadMessages(); } + clearSenderFilter(): void { + this.senderFilter = []; + this.currentPage.set(1); + this.loadMessages(); + } + + removeSenderFilter(sender: string): void { + this.senderFilter = this.senderFilter.filter(s => s !== sender); + this.currentPage.set(1); + this.loadMessages(); + } + clearPriorityFilter(): void { this.priorityFilter = []; this.currentPage.set(1); this.loadMessages(); } + onDateRangeChange(dates: [Date, Date] | null): void { + this.dateRange = dates; + if (dates) { + this.dateFilterVisible = false; + } + this.currentPage.set(1); + this.loadMessages(); + } + + clearDateRange(): void { + this.dateRange = null; + this.currentPage.set(1); + this.loadMessages(); + } + clearAllFilters(): void { this.searchText = ''; this.appliedSearchText = ''; this.channelFilter = []; + this.senderFilter = []; this.priorityFilter = []; + this.dateRange = null; this.currentPage.set(1); this.loadMessages(); } hasActiveFilters(): boolean { - return !!this.appliedSearchText || this.channelFilter.length > 0 || this.priorityFilter.length > 0; + return !!this.appliedSearchText || this.channelFilter.length > 0 || this.senderFilter.length > 0 || this.priorityFilter.length > 0 || !!this.dateRange; } - getChannelDisplayName(internalName: string): string { + getChannelDisplayName(channelId: string): string { const filters = this.channelFilters(); - const channel = filters.find(f => f.value === internalName); - return channel?.text?.toString() ?? internalName; + const channel = filters.find(f => f.value === channelId); + return channel?.text?.toString() ?? channelId; + } + + getDateRangeDisplay(): string { + if (!this.dateRange) return ''; + const format = (d: Date) => d.toLocaleDateString(); + return `${format(this.dateRange[0])} - ${format(this.dateRange[1])}`; } goToPage(page: number): void {