diff --git a/package-lock.json b/package-lock.json index 78413ded..c9e8f331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@ngneat/transloco": "^5.0.7", "@ngneat/until-destroy": "^10.0.0", "ace-builds": "^1.24.2", + "chart.js": "^4.4.1", "rxjs": "~7.8.0", "source-map-support": "^0.5.21", "swagger-ui": "^5.6.1", @@ -4165,6 +4166,12 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -8139,6 +8146,18 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/chart.js": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/cheerio": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz", diff --git a/package.json b/package.json index 02241f7e..77bde497 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve --proxy-config proxy.conf.json", + "start": "ng serve --host 0.0.0.0 --proxy-config proxy.conf.json", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "jest --verbose", @@ -34,6 +34,7 @@ "@ngneat/transloco": "^5.0.7", "@ngneat/until-destroy": "^10.0.0", "ace-builds": "^1.24.2", + "chart.js": "^4.4.1", "rxjs": "~7.8.0", "source-map-support": "^0.5.21", "swagger-ui": "^5.6.1", diff --git a/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts b/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts index 0673ea89..616078c7 100644 --- a/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts +++ b/src/app/adf-apps/df-manage-apps/df-manage-apps-table.component.ts @@ -17,6 +17,9 @@ import { generateApiKey } from 'src/app/shared/utilities/hash'; import { DfSystemConfigDataService } from 'src/app/shared/services/df-system-config-data.service'; import { AdditonalAction } from 'src/app/shared/types/table'; import { DfSnackbarService } from 'src/app/shared/services/df-snackbar.service'; +import { DfDuplicateDialogComponent } from 'src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component'; +import { faCopy } from '@fortawesome/free-solid-svg-icons'; +import { catchError, throwError } from 'rxjs'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -72,7 +75,7 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { row.name ); this.appsService - .update(row.id, { apiKey: newKey }) + .update(row.id, { api_key: newKey }) .subscribe(() => this.refreshTable()); }, ariaLabel: { @@ -81,10 +84,34 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { disabled: row => row.createdById === null, }, ]; + + // Add duplicate action before delete action + const duplicateAction = { + label: 'duplicate', + function: (row: AppRow) => this.duplicateApp(row), + ariaLabel: { + key: 'duplicateApp', + param: 'name', + }, + icon: faCopy, + }; + if (this.actions.additional) { + // Find the delete action index + const deleteIndex = this.actions.additional.findIndex( + action => action.label === 'delete' + ); + if (deleteIndex !== -1) { + // Insert duplicate before delete + this.actions.additional.splice(deleteIndex, 0, duplicateAction); + } else { + // Add at the beginning if no delete found + this.actions.additional.unshift(duplicateAction); + } + // Add the extra actions at the end this.actions.additional.push(...extraActions); } else { - this.actions.additional = extraActions; + this.actions.additional = [duplicateAction, ...extraActions]; } } override columns = [ @@ -149,4 +176,81 @@ export class DfManageAppsTableComponent extends DfManageTableComponent { this.tableLength = data.meta.count; }); } + + duplicateApp(row: AppRow): void { + // First, get the full app details + this.appsService + .get(row.id) + .pipe( + catchError(error => { + console.error('Failed to fetch app details:', error); + return throwError(() => error); + }) + ) + .subscribe(app => { + // Get all existing app names for validation + this.appsService + .getAll>({ limit: 1000 }) + .subscribe(allApps => { + const existingNames = allApps.resource.map(a => a.name); + + const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { + width: '400px', + data: { + title: 'apps.duplicate.title', + message: 'apps.duplicate.message', + label: 'apps.duplicate.nameLabel', + originalName: app.name, + existingNames: existingNames, + }, + }); + + dialogRef.afterClosed().subscribe(newName => { + if (newName) { + // Create a copy of the app with the new name + // Using snake_case as expected by the API + // Note: API key is generated server-side, not sent in payload + const duplicatedApp = { + name: newName, + description: `${app.description || ''} (copy)`, + is_active: app.isActive, + type: app.type, + role_id: app.roleId || null, + // Copy app location specific fields + url: app.url || null, + storage_service_id: app.storageServiceId || null, + storage_container: app.storageContainer || null, + path: app.path || null, + // Copy additional settings + requires_fullscreen: app.requiresFullscreen, + allow_fullscreen_toggle: app.allowFullscreenToggle, + toggle_location: app.toggleLocation, + }; + + // Wrap in resource array as expected by the API + const payload = { + resource: [duplicatedApp], + }; + + // Create the new app + this.appsService + .create(payload, { + snackbarSuccess: 'apps.alerts.duplicateSuccess', + fields: '*', + related: 'role_by_role_id', + }) + .pipe( + catchError(error => { + console.error('Failed to duplicate app:', error); + return throwError(() => error); + }) + ) + .subscribe(() => { + this.refreshTable(); + }); + } + }); + }); + }); + } } diff --git a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html new file mode 100644 index 00000000..4626c566 --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.html @@ -0,0 +1,29 @@ + + +
+
+ +
+
+ + + {{ trend }}% + +
+
+ +
+

{{ title }}

+
{{ value }}
+

{{ subtitle }}

+ +
+ + +
+
diff --git a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss new file mode 100644 index 00000000..8b9ebee6 --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.scss @@ -0,0 +1,173 @@ +.dashboard-card { + height: 100%; + transition: + transform 0.2s ease-in-out, + box-shadow 0.2s ease-in-out; + cursor: default; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + } + + .mat-card-content { + padding: 20px; + } +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; +} + +.icon-container { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + + fa-icon { + font-size: 24px; + color: white; + } + + &.icon-primary { + background: linear-gradient(135deg, #7f11e0 0%, #5c239a 100%); + } + + &.icon-accent { + background: linear-gradient(135deg, #ff4081 0%, #e91e63 100%); + } + + &.icon-success { + background: linear-gradient(135deg, #4caf50 0%, #388e3c 100%); + } + + &.icon-info { + background: linear-gradient(135deg, #2196f3 0%, #1976d2 100%); + } + + &.icon-warn { + background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%); + } +} + +.header-stats { + .trend { + font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; + + &.trend-up { + color: #4caf50; + } + + &.trend-down { + color: #f44336; + } + + fa-icon { + font-size: 12px; + } + } +} + +.card-body { + .card-title { + font-size: 14px; + font-weight: 400; + color: #666; + margin: 0 0 8px 0; + } + + .card-value { + font-size: 32px; + font-weight: 600; + color: #333; + line-height: 1.2; + } + + .card-subtitle { + font-size: 12px; + color: #999; + margin: 4px 0 0 0; + } +} + +.card-footer { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(0, 0, 0, 0.1); + + .footer-text { + font-size: 12px; + color: #666; + } +} + +// Prompt styling +::ng-deep [prompt] { + font-size: 13px; + color: #7f11e0; + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; + animation: pulse 2s infinite; + + &:before { + content: '👆'; + font-size: 16px; + } +} + +@keyframes pulse { + 0% { + opacity: 0.8; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.8; + } +} + +// Dark theme +:host-context(.dark-theme) { + .dashboard-card { + background-color: #424242; + + .card-body { + .card-title { + color: #bbb; + } + + .card-value { + color: #fff; + } + + .card-subtitle { + color: #999; + } + } + + .card-footer { + border-top-color: rgba(255, 255, 255, 0.1); + + .footer-text { + color: #bbb; + } + } + } + + ::ng-deep [prompt] { + color: #bb86fc; + } +} diff --git a/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts new file mode 100644 index 00000000..1070453d --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard-card/df-dashboard-card.component.ts @@ -0,0 +1,26 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +@Component({ + selector: 'df-dashboard-card', + templateUrl: './df-dashboard-card.component.html', + styleUrls: ['./df-dashboard-card.component.scss'], + standalone: true, + imports: [CommonModule, MatCardModule, FontAwesomeModule], +}) +export class DfDashboardCardComponent { + @Input() icon!: IconDefinition; + @Input() title!: string; + @Input() value!: number | string; + @Input() subtitle?: string; + @Input() trend?: number; + @Input() trendIcon?: IconDefinition; + @Input() trendClass?: string; + @Input() footerText?: string; + @Input() showPrompt?: boolean = false; + @Input() color: 'primary' | 'accent' | 'success' | 'info' | 'warn' = + 'primary'; +} diff --git a/src/app/adf-home/df-dashboard/df-dashboard.component.html b/src/app/adf-home/df-dashboard/df-dashboard.component.html new file mode 100644 index 00000000..adef0bcf --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard.component.html @@ -0,0 +1,57 @@ +
+

{{ 'home.dashboard.title' | transloco }}

+ +
+ + +

{{ 'home.dashboard.loading' | transloco }}

+
+
+
+ +
+ +
+ + +
+ {{ 'home.dashboard.services.createPrompt' | transloco }} +
+
+ + + + + + + + +
+
+ +
+ + +

{{ 'home.dashboard.error' | transloco }}

+
+
+
+
diff --git a/src/app/adf-home/df-dashboard/df-dashboard.component.scss b/src/app/adf-home/df-dashboard/df-dashboard.component.scss new file mode 100644 index 00000000..f509ec37 --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard.component.scss @@ -0,0 +1,86 @@ +.dashboard-container { + padding: 24px; + max-width: 1400px; + margin: 0 auto; + + &.dark-theme { + .dashboard-title { + color: #ffffff; + } + + .loading-card, + .error-card { + background-color: #424242; + } + + .performance-card, + .storage-card { + background-color: #424242; + color: #ffffff; + } + } +} + +.dashboard-title { + font-size: 24px; + font-weight: 500; + margin-bottom: 24px; + color: #333; +} + +.loading-container, +.error-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +} + +.loading-card, +.error-card { + text-align: center; + padding: 32px; +} + +.error-icon { + font-size: 48px; + color: #f44336; + margin-bottom: 16px; +} + +.dashboard-content { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin-bottom: 24px; +} + +// Mobile responsiveness +@media (max-width: 768px) { + .dashboard-container { + padding: 16px; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .charts-row { + grid-template-columns: 1fr; + } +} diff --git a/src/app/adf-home/df-dashboard/df-dashboard.component.ts b/src/app/adf-home/df-dashboard/df-dashboard.component.ts new file mode 100644 index 00000000..7b9192fe --- /dev/null +++ b/src/app/adf-home/df-dashboard/df-dashboard.component.ts @@ -0,0 +1,98 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDividerModule } from '@angular/material/divider'; +import { TranslocoModule } from '@ngneat/transloco'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faPlug, faKey, faLock } from '@fortawesome/free-solid-svg-icons'; +import { Subject, takeUntil } from 'rxjs'; +import { + DfAnalyticsService, + DashboardStats, +} from '../../shared/services/df-analytics.service'; +import { DfThemeService } from '../../shared/services/df-theme.service'; +import { DfBreakpointService } from '../../shared/services/df-breakpoint.service'; +import { DfDashboardCardComponent } from './df-dashboard-card/df-dashboard-card.component'; + +@Component({ + selector: 'df-dashboard', + templateUrl: './df-dashboard.component.html', + styleUrls: ['./df-dashboard.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatIconModule, + MatProgressBarModule, + MatTooltipModule, + MatDividerModule, + TranslocoModule, + FontAwesomeModule, + DfDashboardCardComponent, + ], +}) +export class DfDashboardComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + // Icons + faPlug = faPlug; // For API Services + faKey = faKey; // For API Keys + faLock = faLock; // For Roles + + stats: DashboardStats = { + services: { total: 0 }, + apiKeys: { total: 0 }, + roles: { total: 0 }, + }; + loading = true; + error = false; + + constructor( + private analyticsService: DfAnalyticsService, + public themeService: DfThemeService, + public breakpointService: DfBreakpointService + ) {} + + ngOnInit(): void { + // Clear cache to ensure fresh data + localStorage.removeItem('df_dashboard_stats'); + this.loadDashboardStats(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private loadDashboardStats(): void { + this.loading = true; + this.error = false; + + this.analyticsService + .getDashboardStats() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: stats => { + this.stats = stats || { + services: { total: 0 }, + apiKeys: { total: 0 }, + roles: { total: 0 }, + }; + this.loading = false; + }, + error: () => { + this.error = true; + this.loading = false; + // Keep default stats even on error + this.stats = { + services: { total: 0 }, + apiKeys: { total: 0 }, + roles: { total: 0 }, + }; + }, + }); + } +} diff --git a/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss b/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss index f8f51e9f..2369516d 100644 --- a/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss +++ b/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.scss @@ -1,36 +1,45 @@ .df-generate-api-card { cursor: pointer; width: 100%; - min-height: 115px; + min-height: 160px; display: flex; flex-direction: column; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } } .df-generate-api-card-content { display: flex; flex-direction: column; align-items: left; - padding: 12px; + padding: 20px; flex-grow: 1; overflow: hidden; fa-icon { - margin-bottom: 8px; + margin-bottom: 12px; flex-shrink: 0; + font-size: 2rem; } .df-card-header { - font-size: 14px; - font-weight: 500; - margin-bottom: 0px; - line-height: 1.2; + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; + line-height: 1.3; } .df-card-description { - font-size: 12px; - line-height: 1.4; + font-size: 14px; + line-height: 1.5; display: -webkit-box; - line-clamp: 2; - -webkit-line-clamp: 2; + line-clamp: 3; + -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; diff --git a/src/app/adf-home/df-welcome-page/df-welcome-page.component.html b/src/app/adf-home/df-welcome-page/df-welcome-page.component.html index c0edaf1b..bea621ea 100644 --- a/src/app/adf-home/df-welcome-page/df-welcome-page.component.html +++ b/src/app/adf-home/df-welcome-page/df-welcome-page.component.html @@ -22,9 +22,14 @@

[cardFinalHeaderColor]="card.headerColor"> -

- {{ 'home.welcomePage.needHelpChoosing' | transloco }} -

+ + + +
+ +
+ +

{{ 'home.welcomePage.learnMoreHeading' | transloco }}

@@ -46,46 +51,6 @@

-
diff --git a/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss b/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss index f28920c5..787637fe 100644 --- a/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss +++ b/src/app/adf-home/df-welcome-page/df-welcome-page.component.scss @@ -24,14 +24,14 @@ $df-coral-palette: mat.define-palette(theme.$df-coral-palette); } .flex-column { - flex-basis: 50%; + flex-basis: 100%; &:first-child { - padding-right: 25px; + padding-right: 0; } &:last-child { - padding-left: 25px; + padding-left: 0; } } @@ -119,34 +119,31 @@ mat-card.notice-card { df-generate-api-card { width: 32%; + min-height: 180px; - @media (min-width: 960px) and (max-width: 1480px) { + @media (max-width: 959px) { width: 48%; } @media (max-width: 600px) { - width: 48%; - } - - @media (max-width: 350px) { - width: 98%; + width: 100%; } } } -#need-help-choosing-note, #learn-more-heading { text-align: center; margin: 0; } -#need-help-choosing-note { - font-size: 12px; -} - .learn-more-links-list { display: flex; justify-content: space-evenly; flex-wrap: wrap; gap: 10px; } + +.dashboard-section { + margin-top: 48px; + margin-bottom: 32px; +} diff --git a/src/app/adf-home/df-welcome-page/df-welcome-page.component.ts b/src/app/adf-home/df-welcome-page/df-welcome-page.component.ts index a5d6ff9b..549685c6 100644 --- a/src/app/adf-home/df-welcome-page/df-welcome-page.component.ts +++ b/src/app/adf-home/df-welcome-page/df-welcome-page.component.ts @@ -40,6 +40,7 @@ import { SERVICES_SERVICE_TOKEN } from 'src/app/shared/constants/tokens'; import { DFStorageService } from 'src/app/shared/services/df-storage.service'; import { ROUTES } from '../../shared/types/routes'; import { DfGenerateApiCardComponent } from './df-generate-api-card/df-generate-api-card.component'; +import { DfDashboardComponent } from '../df-dashboard/df-dashboard.component'; @Component({ selector: 'df-welcome-page', @@ -63,6 +64,7 @@ import { DfGenerateApiCardComponent } from './df-generate-api-card/df-generate-a MatButtonModule, MatIconModule, DfGenerateApiCardComponent, + DfDashboardComponent, ], providers: [DfBaseCrudService], }) diff --git a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts index 4a317908..4e3215cc 100644 --- a/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts +++ b/src/app/adf-roles/df-manage-roles/df-manage-roles-table.component.ts @@ -20,6 +20,9 @@ import { animate, transition, } from '@angular/animations'; +import { DfDuplicateDialogComponent } from 'src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component'; +import { faCopy } from '@fortawesome/free-solid-svg-icons'; +import { catchError, throwError } from 'rxjs'; @UntilDestroy({ checkProperties: true }) @Component({ selector: 'df-manage-roles-table', @@ -53,6 +56,29 @@ export class DfManageRolesTableComponent extends DfManageTableComponent dialog: MatDialog ) { super(router, activatedRoute, liveAnnouncer, translateService, dialog); + + // Add duplicate action + const duplicateAction = { + label: 'duplicate', + function: (row: RoleRow) => this.duplicateRole(row), + ariaLabel: { + key: 'duplicateRole', + param: 'name', + }, + icon: faCopy, + }; + + if (this.actions.additional) { + // Insert duplicate action before delete action + const deleteIndex = this.actions.additional.findIndex( + action => action.label === 'delete' + ); + if (deleteIndex !== -1) { + this.actions.additional.splice(deleteIndex, 0, duplicateAction); + } else { + this.actions.additional.push(duplicateAction); + } + } } filterQuery = getFilterQuery('roles'); @@ -103,4 +129,98 @@ export class DfManageRolesTableComponent extends DfManageTableComponent this.tableLength = data.meta.count; }); } + + duplicateRole(row: RoleRow): void { + // First, get the full role details with related data + this.roleService + .get(row.id, { + related: 'role_service_access_by_role_id,lookup_by_role_id', + }) + .pipe( + catchError(error => { + console.error('Failed to fetch role details:', error); + return throwError(() => error); + }) + ) + .subscribe(roleData => { + // Get all existing role names for validation + this.roleService + .getAll>({ limit: 1000 }) + .subscribe(allRoles => { + const existingNames = allRoles.resource.map(r => r.name); + + const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { + width: '400px', + data: { + title: 'roles.duplicate.title', + message: 'roles.duplicate.message', + label: 'roles.duplicate.nameLabel', + originalName: roleData.name, + existingNames: existingNames, + }, + }); + + dialogRef.afterClosed().subscribe(newName => { + if (newName) { + // Create a copy of the role with all its configurations + // Using snake_case for the API payload + const duplicatedRole = { + name: newName, + description: `${roleData.description || ''} (copy)`, + is_active: roleData.isActive || roleData.is_active, + // Copy service access permissions - check both camelCase and snake_case + role_service_access_by_role_id: + (roleData.roleServiceAccessByRoleId || roleData.role_service_access_by_role_id)?.map( + (access: any) => ({ + service_id: access.serviceId || access.service_id, + component: access.component, + verb_mask: access.verbMask || access.verb_mask, + requestor_mask: access.requestorMask || access.requestor_mask, + filters: + access.filters?.map((filter: any) => ({ + name: filter.name || filter.field, + operator: filter.operator, + value: filter.value, + })) || [], + filter_op: access.filterOp || access.filter_op || 'AND', + }) + ) || [], + // Copy lookup keys - check both camelCase and snake_case + lookup_by_role_id: + (roleData.lookupByRoleId || roleData.lookup_by_role_id)?.map((lookup: any) => ({ + name: lookup.name, + value: lookup.value, + private: lookup.private, + description: lookup.description, + })) || [], + }; + + // Wrap in resource array as expected by the API + const payload = { + resource: [duplicatedRole], + }; + + console.log('Sending payload:', JSON.stringify(payload, null, 2)); + + // Create the new role + this.roleService + .create(payload, { + snackbarSuccess: 'roles.alerts.duplicateSuccess', + fields: '*', + related: 'role_service_access_by_role_id,lookup_by_role_id', + }) + .pipe( + catchError(error => { + console.error('Failed to duplicate role:', error); + return throwError(() => error); + }) + ) + .subscribe(() => { + this.refreshTable(); + }); + } + }); + }); + }); + } } diff --git a/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts b/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts index e59be6bd..21006627 100644 --- a/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts +++ b/src/app/adf-services/df-manage-services/df-manage-services-table.component.ts @@ -13,6 +13,9 @@ import { GenericListResponse } from 'src/app/shared/types/generic-http'; import { Service, ServiceRow, ServiceType } from 'src/app/shared/types/service'; import { getFilterQuery } from 'src/app/shared/utilities/filter-queries'; import { UntilDestroy } from '@ngneat/until-destroy'; +import { DfDuplicateDialogComponent } from 'src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component'; +import { faCopy } from '@fortawesome/free-solid-svg-icons'; +import { catchError, throwError } from 'rxjs'; @UntilDestroy({ checkProperties: true }) @Component({ selector: 'df-manage-services-table', @@ -49,6 +52,29 @@ export class DfManageServicesTableComponent extends DfManageTableComponent action.label !== 'delete' ) ?? null, }; + } else { + // Add duplicate action for non-system services + const duplicateAction = { + label: 'duplicate', + function: (row: ServiceRow) => this.duplicateService(row), + ariaLabel: { + key: 'duplicateService', + param: 'name', + }, + icon: faCopy, + }; + + if (this.actions.additional) { + // Insert duplicate action before delete action + const deleteIndex = this.actions.additional.findIndex( + action => action.label === 'delete' + ); + if (deleteIndex !== -1) { + this.actions.additional.splice(deleteIndex, 0, duplicateAction); + } else { + this.actions.additional.push(duplicateAction); + } + } } }); } @@ -163,4 +189,70 @@ export class DfManageServicesTableComponent extends DfManageTableComponent(row.id) + .pipe( + catchError(error => { + console.error('Failed to fetch service details:', error); + return throwError(() => error); + }) + ) + .subscribe(service => { + // Get all existing service names for validation + this.serviceService + .getAll>({ limit: 1000 }) + .subscribe(allServices => { + const existingNames = allServices.resource.map(s => s.name); + + const dialogRef = this.dialog.open(DfDuplicateDialogComponent, { + width: '400px', + data: { + title: 'services.duplicate.title', + message: 'services.duplicate.message', + label: 'services.duplicate.nameLabel', + originalName: service.name, + existingNames: existingNames, + }, + }); + + dialogRef.afterClosed().subscribe(newName => { + if (newName) { + // Create a copy of the service with the new name, including all config + const duplicatedService = { + name: newName, + label: service.label || newName, + description: `${service.description || ''} (copy)`, + is_active: service.isActive, + type: service.type, + // Copy the entire config object which contains all service-specific settings + config: service.config ? { ...service.config } : {}, + }; + + // Wrap in resource array as expected by the API + const payload = { + resource: [duplicatedService], + }; + + // Create the new service + this.serviceService + .create(payload, { + snackbarSuccess: 'services.alerts.duplicateSuccess', + }) + .pipe( + catchError(error => { + console.error('Failed to duplicate service:', error); + return throwError(() => error); + }) + ) + .subscribe(() => { + this.refreshTable(); + }); + } + }); + }); + }); + } } diff --git a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html new file mode 100644 index 00000000..b4fbcddb --- /dev/null +++ b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.html @@ -0,0 +1,34 @@ +

{{ data.title | transloco }}

+
+

{{ data.message | transloco }}

+ + {{ data.label | transloco }} + + + {{ 'validation.required' | transloco }} + + + {{ 'validation.nameExists' | transloco }} + + + {{ 'validation.sameAsOriginal' | transloco }} + + +
+
+ + +
diff --git a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss new file mode 100644 index 00000000..3db0dee7 --- /dev/null +++ b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.scss @@ -0,0 +1,3 @@ +.full-width { + width: 100%; +} diff --git a/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts new file mode 100644 index 00000000..bf3847a2 --- /dev/null +++ b/src/app/shared/components/df-duplicate-dialog/df-duplicate-dialog.component.ts @@ -0,0 +1,78 @@ +import { Component, Inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + FormControl, + ReactiveFormsModule, + Validators, + AbstractControl, + ValidationErrors, +} from '@angular/forms'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { TranslocoPipe } from '@ngneat/transloco'; +import { NgIf } from '@angular/common'; + +export interface DuplicateDialogData { + title: string; + message: string; + label: string; + originalName: string; + existingNames?: string[]; +} + +@Component({ + selector: 'df-duplicate-dialog', + templateUrl: './df-duplicate-dialog.component.html', + styleUrls: ['./df-duplicate-dialog.component.scss'], + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + TranslocoPipe, + NgIf, + ], +}) +export class DfDuplicateDialogComponent { + nameControl: FormControl; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: DuplicateDialogData + ) { + this.nameControl = new FormControl('', [ + Validators.required, + this.uniqueNameValidator.bind(this), + ]); + } + + uniqueNameValidator(control: AbstractControl): ValidationErrors | null { + if ( + this.data.existingNames && + this.data.existingNames.includes(control.value) + ) { + return { nameExists: true }; + } + if (control.value === this.data.originalName) { + return { sameName: true }; + } + return null; + } + + onDuplicate(): void { + if (this.nameControl.valid) { + this.dialogRef.close(this.nameControl.value); + } + } + + onCancel(): void { + this.dialogRef.close(null); + } +} diff --git a/src/app/shared/constants/home.ts b/src/app/shared/constants/home.ts index 2a9803c4..b9bd6785 100644 --- a/src/app/shared/constants/home.ts +++ b/src/app/shared/constants/home.ts @@ -30,7 +30,7 @@ const videoTutorials = { const fullDocumentation = { name: 'home.resourceLinks.fullDocumentation', icon: faBook, - link: 'https://wiki.dreamfactory.com/', + link: 'https://docs.dreamfactory.com/', }; const communityForum = { diff --git a/src/app/shared/services/df-analytics.service.ts b/src/app/shared/services/df-analytics.service.ts new file mode 100644 index 00000000..b4808836 --- /dev/null +++ b/src/app/shared/services/df-analytics.service.ts @@ -0,0 +1,181 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, of, timer, forkJoin } from 'rxjs'; +import { catchError, map, shareReplay, switchMap, tap } from 'rxjs/operators'; + +export interface DashboardStats { + services: { + total: number; + }; + apiKeys: { + total: number; + }; + roles: { + total: number; + }; +} + +@Injectable({ + providedIn: 'root', +}) +export class DfAnalyticsService { + private readonly CACHE_KEY = 'df_dashboard_stats'; + private readonly CACHE_DURATION = 30 * 1000; // 30 seconds + private readonly REFRESH_INTERVAL = 15 * 60 * 1000; // 15 minutes + + private stats$: Observable; + + constructor(private http: HttpClient) { + // Initialize the stats observable with automatic refresh + this.stats$ = timer(0, this.REFRESH_INTERVAL).pipe( + switchMap(() => this.fetchStats()), + shareReplay(1) + ); + } + + getDashboardStats(): Observable { + // Check localStorage cache first + const cached = this.getCachedStats(); + if (cached) { + return of(cached); + } + + return this.stats$; + } + + private fetchStats(): Observable { + // Fetch minimal data to filter out system services + const requests = { + services: this.http.get( + '/api/v2/system/service?fields=id,name,type&include_count=true' + ), + roles: this.http.get( + '/api/v2/system/role?fields=id,name&include_count=true' + ), + appKeys: this.http.get('/api/v2/system/app?include_count=true'), + }; + + return forkJoin(requests).pipe( + map(responses => this.transformResponses(responses)), + tap(stats => this.cacheStats(stats)), + catchError(() => { + // Return simple fallback data if API fails + return of(this.getSimpleStats()); + }) + ); + } + + private transformResponses(responses: any): DashboardStats { + const { services, roles, appKeys } = responses; + + // System service names to exclude (comprehensive list of DreamFactory system services) + // System service names to exclude (comprehensive list of DreamFactory system services) + const systemServiceNames = [ + 'system', + 'api_docs', + 'files', + 'logs', + 'db', + 'email', + 'user', + 'script', + 'ui', + 'schema', + 'api_doc', + 'file', + 'log', + 'admin', + 'df-admin', + 'dreamfactory', + 'cache', + 'push', + 'pub_sub', + ].map(s => s.toLowerCase()); + // Common system app names - being very specific to avoid filtering user apps + const systemAppNames = ['admin', 'api_docs', 'file_manager'].map(s => + s.toLowerCase() + ); + const systemRoleNames = ['administrator', 'user', 'admin', 'sys_admin'].map( + s => s.toLowerCase() + ); + + // Filter services - exclude system services by name + const userServices = (services.resource || []).filter((s: any) => { + return !systemServiceNames.includes(s.name.toLowerCase()); + }); + + // Filter API Keys - exclude system apps by name + const userApiKeys = (appKeys.resource || []).filter((a: any) => { + // Check multiple possible field names for API key + const apiKeyValue = a.apiKey || a.api_key || a.apikey; + const hasApiKey = !!apiKeyValue; + const isSystemApp = systemAppNames.includes(a.name.toLowerCase()); + + return !isSystemApp && hasApiKey; + }); + + // Filter roles - exclude system roles by name + const userRoles = (roles.resource || []).filter((r: any) => { + return !systemRoleNames.includes(r.name.toLowerCase()); + }); + + return { + services: { + total: userServices.length, + }, + apiKeys: { + total: userApiKeys.length, + }, + roles: { + total: userRoles.length, + }, + }; + } + + private calculateTrend(previous: number, current: number): number { + if (previous === 0) return 0; + return Math.round(((current - previous) / previous) * 100); + } + + private getCachedStats(): DashboardStats | null { + const cached = localStorage.getItem(this.CACHE_KEY); + if (!cached) return null; + + try { + const { data, timestamp } = JSON.parse(cached); + if (Date.now() - timestamp < this.CACHE_DURATION) { + return data; + } + } catch { + // Invalid cache + } + + localStorage.removeItem(this.CACHE_KEY); + return null; + } + + private cacheStats(stats: DashboardStats): void { + localStorage.setItem( + this.CACHE_KEY, + JSON.stringify({ + data: stats, + timestamp: Date.now(), + }) + ); + } + + private getSimpleStats(): DashboardStats { + // Return simple fallback data + return { + services: { + total: 0, + }, + apiKeys: { + total: 0, + }, + roles: { + total: 0, + }, + }; + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 9bd03a0e..c9493ce7 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -87,6 +87,10 @@ "confirm": "Confirm", "label": "Label", "confirmDelete": "Are you sure you want to delete this?", + "duplicate": "Duplicate", + "duplicateService": "Duplicate service {{name}}", + "duplicateRole": "Duplicate role {{name}}", + "duplicateApp": "Duplicate app {{name}}", "origins": "Origins", "headers": "Headers", "exposedHeaders": "Exposed Headers", @@ -377,6 +381,41 @@ "duplicateEmail": "Email address already exists", "close": "Close alert" }, + "validation": { + "required": "This field is required", + "nameExists": "This name already exists", + "sameAsOriginal": "Name must be different from original" + }, + "services": { + "alerts": { + "duplicateSuccess": "Service duplicated successfully" + }, + "duplicate": { + "title": "Duplicate Service", + "message": "Enter a new name for the duplicated service", + "nameLabel": "Service Name" + } + }, + "roles": { + "alerts": { + "duplicateSuccess": "Role duplicated successfully" + }, + "duplicate": { + "title": "Duplicate Role", + "message": "Enter a new name for the duplicated role", + "nameLabel": "Role Name" + } + }, + "apps": { + "alerts": { + "duplicateSuccess": "App duplicated successfully" + }, + "duplicate": { + "title": "Duplicate App", + "message": "Enter a new name for the duplicated app", + "nameLabel": "App Name" + } + }, "lookupKeys": { "label": "Lookup Keys", "desc": "Lookup keys for service configuration and credentials must be made private.", diff --git a/src/assets/i18n/home/en.json b/src/assets/i18n/home/en.json index 02b278cb..f79163f1 100644 --- a/src/assets/i18n/home/en.json +++ b/src/assets/i18n/home/en.json @@ -91,5 +91,27 @@ "angularJs": "Angular JS", "angular2": "Angular 2", "react": "React" + }, + "dashboard": { + "title": "Quick Stats", + "loading": "Loading stats...", + "error": "Unable to load stats. Please try again later.", + "services": { + "title": "API Services", + "total": "Your Services", + "createPrompt": "Get started! Create your first API using the cards above" + }, + "admins": { + "title": "Admins", + "total": "Total Admins" + }, + "apiKeys": { + "title": "API Keys", + "total": "Your API Keys" + }, + "roles": { + "title": "Roles", + "total": "Your Roles" + } } } \ No newline at end of file