diff --git a/eslint.config.mjs b/eslint.config.mjs index 502d1eaac29..caa138d511d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,10 +2,7 @@ import eslint from "@eslint/js"; import tsEslint from "typescript-eslint"; import angular from "angular-eslint"; import stylistic from "@stylistic/eslint-plugin"; -import {fixupConfigRules, fixupPluginRules} from "@eslint/compat"; -import { FlatCompat } from "@eslint/eslintrc"; -import {dirname} from "node:path"; -import {fileURLToPath} from "node:url"; +import { fixupPluginRules } from "@eslint/compat"; import rxjsAngular from "eslint-plugin-rxjs-angular"; import angularFileNaming from "eslint-plugin-angular-file-naming"; import importPlugin from "eslint-plugin-import"; @@ -17,11 +14,7 @@ import { eslintSpec } from "./eslint/eslint-spec.mjs"; import { fixLaterRules } from "./eslint/eslint-ts-rules-fix-later.mjs"; import { ruleOverrides } from "./eslint/eslint-ts-rules-overrides.mjs"; import { extraRules } from "./eslint/eslint-ts-rules-extra.mjs"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); +import rxjs from "@smarttools/eslint-plugin-rxjs"; export default tsEslint.config( { @@ -48,6 +41,7 @@ export default tsEslint.config( }, plugins: { unicorn, + rxjs, "rxjs-angular": fixupPluginRules(rxjsAngular), "angular-file-naming": fixupPluginRules(angularFileNaming), "unused-imports": unusedImports, @@ -68,7 +62,7 @@ export default tsEslint.config( }), importPlugin.flatConfigs.recommended, sonarjs.configs.recommended, - ...fixupConfigRules(compat.extends('plugin:rxjs/recommended')), + rxjs.configs.recommended, ], rules: { ...ruleOverrides, diff --git a/eslint/eslint-ts-rules-extra.mjs b/eslint/eslint-ts-rules-extra.mjs index 571fb6c9be9..47c057a6682 100644 --- a/eslint/eslint-ts-rules-extra.mjs +++ b/eslint/eslint-ts-rules-extra.mjs @@ -7,7 +7,7 @@ import airbnbVariables from "eslint-config-airbnb-base/rules/variables"; */ export const extraRules = { // RxJS - "rxjs/no-unsafe-takeuntil": ["error", { + "@smarttools/rxjs/no-unsafe-takeuntil": ["error", { "alias": ["untilDestroyed"] }], "rxjs-angular/prefer-takeuntil": ["error", { @@ -16,14 +16,14 @@ export const extraRules = { "checkDecorators": ["Component"], "checkDestroy": false }], - "rxjs/finnish": ["error", { + "@smarttools/rxjs/finnish": ["error", { "parameters": true, "properties": false, // TODO: Should be true, hard to implement now. "variables": true, "functions": false, "methods": false, }], - "rxjs/prefer-observer": ["error"], + "@smarttools/rxjs/prefer-observer": ["error"], // Angular "@angular-eslint/use-lifecycle-interface": ["error"], diff --git a/eslint/eslint-ts-rules-fix-later.mjs b/eslint/eslint-ts-rules-fix-later.mjs index 76e86f689dd..07927ab4180 100644 --- a/eslint/eslint-ts-rules-fix-later.mjs +++ b/eslint/eslint-ts-rules-fix-later.mjs @@ -8,8 +8,7 @@ export const fixLaterRules = { "@typescript-eslint/no-dynamic-delete": ["off"], "@typescript-eslint/class-literal-property-style": ["off"], "no-prototype-builtins": ["off"], - "rxjs/no-implicit-any-catch": ["off"], - "rxjs/no-nested-subscribe": ["off"], + "@smarttools/rxjs/no-nested-subscribe": ["off"], "sonarjs/prefer-nullish-coalescing": ["off"], "sonarjs/deprecation": ["off"], diff --git a/package.json b/package.json index 71e53113eed..2aa9f07787c 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@sentry/angular": "5.30.0", "@sentry/utils": "~7.42.0", "@shopify/eslint-plugin": "~46.0.0", + "@smarttools/eslint-plugin-rxjs": "~1.0.7", "@stylistic/eslint-plugin": "~2.9.0", "@types/cheerio": "~0.22.35", "@types/d3": "~7.4.3", @@ -147,7 +148,6 @@ "eslint-plugin-angular-test-ids": "~1.0.6", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jest": "~28.8.3", - "eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-rxjs-angular": "^2.0.1", "eslint-plugin-sonarjs": "~2.0.3", "eslint-plugin-unicorn": "^56.0.0", diff --git a/proxy.config.json.template b/proxy.config.json.template index b7b018e5215..a9b8fe84a92 100644 --- a/proxy.config.json.template +++ b/proxy.config.json.template @@ -10,14 +10,14 @@ "target": "http://_REMOTE_", "secure": false, "changeOrigin": true, - "pathRewrite": {"^/_upload" : "http://_REMOTE_:6000/_upload"}, + "pathRewrite": {"^/_upload" : "http://_REMOTE_/_upload"}, "loglevel": "debug" }, "/_download": { "target": "http://_REMOTE_", "secure": false, "changeOrigin": true, - "pathRewrite": {"^/_download" : "http://_REMOTE_:6000/_download"}, + "pathRewrite": {"^/_download" : "http://_REMOTE_/_download"}, "loglevel": "debug" } } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4b5023117b9..64e8c76c15b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -9,6 +9,7 @@ import { WINDOW } from 'app/helpers/window.helper'; import { AuthService } from 'app/services/auth/auth.service'; import { DetectBrowserService } from 'app/services/detect-browser.service'; import { LayoutService } from 'app/services/layout.service'; +import { PingService } from 'app/services/websocket/ping.service'; @UntilDestroy() @Component({ @@ -26,6 +27,7 @@ export class AppComponent implements OnInit { private authService: AuthService, private detectBrowser: DetectBrowserService, private layoutService: LayoutService, + private pingService: PingService, @Inject(WINDOW) private window: Window, ) { this.authService.isAuthenticated$.pipe(untilDestroyed(this)).subscribe((isAuthenticated) => { @@ -52,6 +54,7 @@ export class AppComponent implements OnInit { } } }); + this.pingService.setupPing(); } ngOnInit(): void { diff --git a/src/app/core/guards/translations-loaded.guard.ts b/src/app/core/guards/translations-loaded.guard.ts index 69eb3208b3a..cff1c85886f 100644 --- a/src/app/core/guards/translations-loaded.guard.ts +++ b/src/app/core/guards/translations-loaded.guard.ts @@ -38,7 +38,7 @@ export class TranslationsLoadedGuard { return waitForTranslations$.pipe( timeout(this.maxLanguageLoadingTime), map(() => true), - catchError((error) => { + catchError((error: unknown) => { console.error('Error loading translations: ', error); return of(true); }), diff --git a/src/app/core/guards/websocket-connection.guard.ts b/src/app/core/guards/websocket-connection.guard.ts index 74ac29dac8f..d81dc1d2bd9 100644 --- a/src/app/core/guards/websocket-connection.guard.ts +++ b/src/app/core/guards/websocket-connection.guard.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@angular/core'; +import { Inject, Injectable } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; +import { WINDOW } from 'app/helpers/window.helper'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { WebSocketHandlerService } from 'app/services/websocket/websocket-handler.service'; @@ -16,6 +17,7 @@ export class WebSocketConnectionGuard { private matDialog: MatDialog, private dialogService: DialogService, private translate: TranslateService, + @Inject(WINDOW) private window: Window, ) { this.wsManager.isClosed$.pipe(untilDestroyed(this)).subscribe((isClosed) => { if (isClosed) { @@ -37,7 +39,9 @@ export class WebSocketConnectionGuard { private resetUi(): void { this.closeAllDialogs(); if (!this.wsManager.isSystemShuttingDown) { - this.router.navigate(['/signin']); + // manually preserve query params + const params = new URLSearchParams(this.window.location.search); + this.router.navigate(['/signin'], { queryParams: Object.fromEntries(params) }); } } diff --git a/src/app/core/testing/classes/mock-api.service.ts b/src/app/core/testing/classes/mock-api.service.ts index 8d56dcf9c4f..731e2c41830 100644 --- a/src/app/core/testing/classes/mock-api.service.ts +++ b/src/app/core/testing/classes/mock-api.service.ts @@ -17,9 +17,10 @@ import { ApiJobParams, ApiJobResponse, } from 'app/interfaces/api/api-job-directory.interface'; -import { ApiEvent } from 'app/interfaces/api-message.interface'; +import { ApiEventTyped } from 'app/interfaces/api-message.interface'; import { Job } from 'app/interfaces/job.interface'; import { ApiService } from 'app/services/websocket/api.service'; +import { SubscriptionManagerService } from 'app/services/websocket/subscription-manager.service'; import { WebSocketHandlerService } from 'app/services/websocket/websocket-handler.service'; /** @@ -41,14 +42,15 @@ const anyArgument = when((_: ApiJobParams) => true); */ @Injectable() export class MockApiService extends ApiService { - private subscribeStream$ = new Subject(); + private subscribeStream$ = new Subject(); private jobIdCounter = 1; constructor( - protected override wsHandler: WebSocketHandlerService, - protected override translate: TranslateService, + wsHandler: WebSocketHandlerService, + subscriptionManager: SubscriptionManagerService, + translate: TranslateService, ) { - super(wsHandler, translate); + super(wsHandler, subscriptionManager, translate); this.call = jest.fn(); this.job = jest.fn(); @@ -127,7 +129,7 @@ export class MockApiService extends ApiService { this.jobIdCounter += 1; } - emitSubscribeEvent(event: ApiEvent): void { + emitSubscribeEvent(event: ApiEventTyped): void { this.subscribeStream$.next(event); } } diff --git a/src/app/core/testing/mock-enclosure/mock-enclosure-api.service.ts b/src/app/core/testing/mock-enclosure/mock-enclosure-api.service.ts index d9d180ac117..4fc09a2b4c1 100644 --- a/src/app/core/testing/mock-enclosure/mock-enclosure-api.service.ts +++ b/src/app/core/testing/mock-enclosure/mock-enclosure-api.service.ts @@ -8,6 +8,7 @@ import { MockEnclosureGenerator } from 'app/core/testing/mock-enclosure/mock-enc import { ApiCallMethod, ApiCallParams, ApiCallResponse } from 'app/interfaces/api/api-call-directory.interface'; import { SystemInfo } from 'app/interfaces/system-info.interface'; import { ApiService } from 'app/services/websocket/api.service'; +import { SubscriptionManagerService } from 'app/services/websocket/subscription-manager.service'; import { WebSocketHandlerService } from 'app/services/websocket/websocket-handler.service'; @Injectable({ @@ -19,9 +20,10 @@ export class MockEnclosureApiService extends ApiService { constructor( wsManager: WebSocketHandlerService, + subscriptionManager: SubscriptionManagerService, translate: TranslateService, ) { - super(wsManager, translate); + super(wsManager, subscriptionManager, translate); console.warn('MockEnclosureApiService is in effect. Some calls will be mocked'); } diff --git a/src/app/core/testing/utils/empty-auth.service.ts b/src/app/core/testing/utils/empty-auth.service.ts index caaa144c20f..e1fdec923f5 100644 --- a/src/app/core/testing/utils/empty-auth.service.ts +++ b/src/app/core/testing/utils/empty-auth.service.ts @@ -15,4 +15,5 @@ export class EmptyAuthService { logout = getMissingInjectionErrorFactory(AuthService.name); refreshUser = getMissingInjectionErrorFactory(AuthService.name); loginWithToken = getMissingInjectionErrorFactory(AuthService.name); + setQueryToken = getMissingInjectionErrorFactory(AuthService.name); } diff --git a/src/app/core/testing/utils/mock-api.utils.ts b/src/app/core/testing/utils/mock-api.utils.ts index c366929102c..3dd919b95af 100644 --- a/src/app/core/testing/utils/mock-api.utils.ts +++ b/src/app/core/testing/utils/mock-api.utils.ts @@ -12,6 +12,7 @@ import { ApiCallMethod } from 'app/interfaces/api/api-call-directory.interface'; import { ApiJobDirectory, ApiJobMethod } from 'app/interfaces/api/api-job-directory.interface'; import { Job } from 'app/interfaces/job.interface'; import { ApiService } from 'app/services/websocket/api.service'; +import { SubscriptionManagerService } from 'app/services/websocket/subscription-manager.service'; import { WebSocketHandlerService } from 'app/services/websocket/websocket-handler.service'; /** @@ -47,8 +48,12 @@ export function mockApi( return [ { provide: ApiService, - useFactory: (wsHandler: WebSocketHandlerService, translate: TranslateService) => { - const mockApiService = new MockApiService(wsHandler, translate); + useFactory: ( + wsHandler: WebSocketHandlerService, + translate: TranslateService, + ) => { + const subscriptionManager = {} as SubscriptionManagerService; + const mockApiService = new MockApiService(wsHandler, subscriptionManager, translate); (mockResponses || []).forEach((mockResponse) => { if (mockResponse.type === MockApiResponseType.Call) { mockApiService.mockCall(mockResponse.method, mockResponse.response); diff --git a/src/app/enums/api-error-name.enum.ts b/src/app/enums/api-error-name.enum.ts deleted file mode 100644 index 8b032436db2..00000000000 --- a/src/app/enums/api-error-name.enum.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum ApiErrorName { - NotAuthenticated = 'ENOTAUTHENTICATED', - NoAccess = 'EACCES', - NoMemory = 'ENOMEM', - AlreadyExists = 'EEXIST', - Again = 'EAGAIN', -} diff --git a/src/app/enums/api-message-type.enum.ts b/src/app/enums/api-message-type.enum.ts deleted file mode 100644 index d16283728ed..00000000000 --- a/src/app/enums/api-message-type.enum.ts +++ /dev/null @@ -1,21 +0,0 @@ -export enum IncomingApiMessageType { - Changed = 'changed', - Added = 'added', - Removed = 'removed', - Result = 'result', - Connected = 'connected', - Pong = 'pong', - Method = 'method', - Ready = 'ready', - NoSub = 'nosub', - - // Special type added on the frontend - Discard = 'discard', -} - -export enum OutgoingApiMessageType { - Connect = 'connect', - UnSub = 'unsub', - Sub = 'sub', - Ping = 'ping', -} diff --git a/src/app/enums/api.enum.ts b/src/app/enums/api.enum.ts new file mode 100644 index 00000000000..bee6c599eac --- /dev/null +++ b/src/app/enums/api.enum.ts @@ -0,0 +1,36 @@ +export enum ApiErrorName { + NotAuthenticated = 'ENOTAUTHENTICATED', + NoAccess = 'EACCES', + NoMemory = 'ENOMEM', + AlreadyExists = 'EEXIST', + Again = 'EAGAIN', + Validation = 'EINVAL', +} + +export const apiErrorNames = new Map([ + [ApiErrorName.NotAuthenticated, 'Not Authenticated'], + [ApiErrorName.NoAccess, 'Access Error'], + [ApiErrorName.NoMemory, 'No Memory'], + [ApiErrorName.AlreadyExists, 'Already Exists'], + [ApiErrorName.Again, 'Try Again'], + [ApiErrorName.Validation, 'Validation Error'], +]); + +export enum JsonRpcErrorCode { + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, + TooManyConcurrentCalls = -32000, + CallError = -32001, +} + +export enum CollectionChangeType { + Changed = 'changed', + Added = 'added', + Removed = 'removed', +} + +export enum ShellMessageType { + Connected = 'connected', +} diff --git a/src/app/enums/iscsi.enum.ts b/src/app/enums/iscsi.enum.ts index 782d7035382..fc939d43d3f 100644 --- a/src/app/enums/iscsi.enum.ts +++ b/src/app/enums/iscsi.enum.ts @@ -39,3 +39,9 @@ export const iscsiExtentUseforMap = new Map([ [IscsiExtentUsefor.Legacyos, T('Legacy OS: Extent block size 512b, TPC enabled, no Xen compat mode, SSD speed')], [IscsiExtentUsefor.Modernos, T('Modern OS: Extent block size 4k, TPC enabled, no Xen compat mode, SSD speed')], ]); + +export const iscsiTargetModeNames = new Map([ + [IscsiTargetMode.Iscsi, T('iSCSI')], + [IscsiTargetMode.Fc, T('Fibre Channel')], + [IscsiTargetMode.Both, T('Both')], +]); diff --git a/src/app/enums/response-error-type.enum.ts b/src/app/enums/response-error-type.enum.ts index 1cdeb99d8bf..d8e5a1c43a2 100644 --- a/src/app/enums/response-error-type.enum.ts +++ b/src/app/enums/response-error-type.enum.ts @@ -1,3 +1,3 @@ -export enum ResponseErrorType { +export enum JobExceptionType { Validation = 'VALIDATION', } diff --git a/src/app/helpers/api.helper.ts b/src/app/helpers/api.helper.ts index f1caa51f5dd..52194e65a5f 100644 --- a/src/app/helpers/api.helper.ts +++ b/src/app/helpers/api.helper.ts @@ -1,4 +1,12 @@ +import { isObject } from 'lodash-es'; import { ApiError } from 'app/interfaces/api-error.interface'; +import { + ErrorResponse, + RequestMessage, + IncomingMessage, + CollectionUpdateMessage, SuccessfulResponse, +} from 'app/interfaces/api-message.interface'; +import { Job } from 'app/interfaces/job.interface'; export function isApiError(error: unknown): error is ApiError { if (error === null) return false; @@ -9,3 +17,50 @@ export function isApiError(error: unknown): error is ApiError { && 'reason' in error && 'trace' in error; } + +export function isFailedJob(obj: unknown): obj is Job { + if (obj === null) return false; + + return typeof obj === 'object' + && ('state' in obj + && 'error' in obj + && 'exception' in obj + && 'exc_info' in obj); +} + +export function isIncomingMessage(something: unknown): something is IncomingMessage { + return isObject(something) && 'jsonrpc' in something; +} + +export function isCollectionUpdateMessage(something: unknown): something is CollectionUpdateMessage { + return isIncomingMessage(something) && 'method' in something && something.method === 'collection_update'; +} + +export function isSuccessfulResponse(something: unknown): something is SuccessfulResponse { + return isIncomingMessage(something) + && 'result' in something; +} + +export function isErrorResponse(something: unknown): something is ErrorResponse { + return isIncomingMessage(something) + && 'error' in something + && Boolean(something.error); +} + +/** + * Extract api error if it's available. Otherwise returns undefined. + */ +export function extractApiError(someError: unknown): ApiError | undefined { + if (isErrorResponse(someError)) { + return someError.error.data; + } + + return undefined; +} + +export function makeRequestMessage(message: Pick): RequestMessage { + return { + jsonrpc: '2.0', + ...message, + }; +} diff --git a/src/app/helpers/operators/apply-api-event.operator.spec.ts b/src/app/helpers/operators/apply-api-event.operator.spec.ts index 9ae9ed3c2e0..5c0408e1943 100644 --- a/src/app/helpers/operators/apply-api-event.operator.spec.ts +++ b/src/app/helpers/operators/apply-api-event.operator.spec.ts @@ -1,5 +1,5 @@ import { of } from 'rxjs'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { ApiEventTyped } from 'app/interfaces/api-message.interface'; import { Pool } from 'app/interfaces/pool.interface'; import { applyApiEvent } from './apply-api-event.operator'; @@ -8,7 +8,7 @@ describe('applyApiEvent', () => { it('adds an item when an Added event is received', () => { const items = [{ id: 1 } as Pool]; const event = { - msg: IncomingApiMessageType.Added, + msg: CollectionChangeType.Added, fields: { id: 2 } as Pool, } as ApiEventTyped<'pool.query'>; @@ -20,7 +20,7 @@ describe('applyApiEvent', () => { it('updates an item when a Changed event is received', () => { const items = [{ id: 1, name: 'pool1' } as Pool]; const event = { - msg: IncomingApiMessageType.Added, + msg: CollectionChangeType.Added, fields: { id: 1, name: 'pool2' } as Pool, } as ApiEventTyped<'pool.query'>; @@ -32,7 +32,7 @@ describe('applyApiEvent', () => { it('removes an item when a Removed event is received', () => { const items = [{ id: 1, name: 'pool1' }, { id: 2, name: 'pool2' }] as Pool[]; const event = { - msg: IncomingApiMessageType.Removed, + msg: CollectionChangeType.Removed, id: 1, } as ApiEventTyped<'pool.query'>; diff --git a/src/app/helpers/operators/apply-api-event.operator.ts b/src/app/helpers/operators/apply-api-event.operator.ts index 67e383098c7..48c09ec233a 100644 --- a/src/app/helpers/operators/apply-api-event.operator.ts +++ b/src/app/helpers/operators/apply-api-event.operator.ts @@ -1,5 +1,5 @@ import { OperatorFunction, map } from 'rxjs'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { ApiCallAndSubscribeMethod, ApiCallAndSubscribeResponse } from 'app/interfaces/api/api-call-and-subscribe-directory.interface'; import { ApiCallResponse } from 'app/interfaces/api/api-call-directory.interface'; import { ApiEventTyped } from 'app/interfaces/api-message.interface'; @@ -9,11 +9,11 @@ export function applyApiEvent< >(): OperatorFunction<[ApiCallResponse, ApiEventTyped], ApiCallAndSubscribeResponse[]> { return map(([items, event]) => { switch (event?.msg) { - case IncomingApiMessageType.Added: + case CollectionChangeType.Added: return [...items, event.fields]; - case IncomingApiMessageType.Changed: + case CollectionChangeType.Changed: return items.map((item) => (item.id === event.id ? event.fields : item)); - case IncomingApiMessageType.Removed: + case CollectionChangeType.Removed: return items.filter((item) => item.id !== event.id); default: break; diff --git a/src/app/helpers/operators/to-loading-state.helper.ts b/src/app/helpers/operators/to-loading-state.helper.ts index f68a0b8302f..76e936054d8 100644 --- a/src/app/helpers/operators/to-loading-state.helper.ts +++ b/src/app/helpers/operators/to-loading-state.helper.ts @@ -2,12 +2,11 @@ import { of, OperatorFunction, pipe } from 'rxjs'; import { catchError, map, startWith, } from 'rxjs/operators'; -import { ApiError } from 'app/interfaces/api-error.interface'; export interface LoadingState { isLoading: boolean; value?: T; - error?: ApiError | Error; + error?: unknown; } /** @@ -25,7 +24,7 @@ export interface LoadingState { export function toLoadingState(): OperatorFunction> { return pipe( map((value) => ({ isLoading: false, value })), - catchError((error: ApiError | Error) => of({ isLoading: false, error })), + catchError((error: unknown) => of({ isLoading: false, error })), startWith({ isLoading: true }), ); } diff --git a/src/app/helptext/services/components/service-smb.ts b/src/app/helptext/services/components/service-smb.ts index af176200ae6..6ab9db6ed68 100644 --- a/src/app/helptext/services/components/service-smb.ts +++ b/src/app/helptext/services/components/service-smb.ts @@ -24,8 +24,7 @@ export const helptextServiceSmb = { cifs_srv_unixcharset_tooltip: T('Default is UTF-8 which supports all characters in\ all languages.'), - cifs_srv_loglevel_tooltip: T('Record SMB service messages up to the specified log level. \ - By default, error and warning level messages are logged.'), + cifs_srv_debug_tooltip: T('Use this option to log more detailed information about SMB.'), cifs_srv_syslog_tooltip: T('Set to log authentication failures in /var/log/messages\ instead of the default of /var/log/samba4/log.smbd.'), cifs_srv_localmaster_tooltip: T('Set to determine if the system participates in\ diff --git a/src/app/interfaces/api-error.interface.ts b/src/app/interfaces/api-error.interface.ts index 62f7828d625..cc8bb48d503 100644 --- a/src/app/interfaces/api-error.interface.ts +++ b/src/app/interfaces/api-error.interface.ts @@ -1,5 +1,4 @@ -import { ApiErrorName } from 'app/enums/api-error-name.enum'; -import { ResponseErrorType } from 'app/enums/response-error-type.enum'; +import { ApiErrorName } from 'app/enums/api.enum'; export interface ApiError { errname: ApiErrorName; @@ -7,7 +6,6 @@ export interface ApiError { extra: unknown; reason: string; trace: ApiErrorTrace; - type: ResponseErrorType | null; message?: string | null; } diff --git a/src/app/interfaces/api-message.interface.ts b/src/app/interfaces/api-message.interface.ts index 36f3a0afc5d..e445b16594b 100644 --- a/src/app/interfaces/api-message.interface.ts +++ b/src/app/interfaces/api-message.interface.ts @@ -1,39 +1,58 @@ -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType, JsonRpcErrorCode } from 'app/enums/api.enum'; import { ApiCallMethod } from 'app/interfaces/api/api-call-directory.interface'; import { ApiEventDirectory } from 'app/interfaces/api/api-event-directory.interface'; import { ApiJobMethod } from 'app/interfaces/api/api-job-directory.interface'; import { ApiError } from 'app/interfaces/api-error.interface'; -export interface PongMessage { - id: string; - msg: IncomingApiMessageType.Pong; +/** + * General documentation about message format: https://www.jsonrpc.org/specification + */ +interface BaseJsonRpc { + jsonrpc: '2.0'; } -export interface SubscriptionReadyMessage { - msg: IncomingApiMessageType.Ready; - subs: string[]; +export interface RequestMessage extends BaseJsonRpc { + id?: string; + method: ApiMethod; + params?: unknown[]; } -export interface ResultMessage { +export interface SuccessfulResponse extends BaseJsonRpc { id: string; - msg: IncomingApiMessageType.Result; - result?: T; - error?: ApiError; + result: unknown; } -export interface ConnectedMessage { - msg: IncomingApiMessageType.Connected; - session: string; +export interface ErrorResponse extends BaseJsonRpc { + id: string; + error: JsonRpcError; } +export interface CollectionUpdateMessage extends BaseJsonRpc { + method: 'collection_update'; + params: ApiEvent; +} + +export interface JsonRpcError { + code: JsonRpcErrorCode; + message: string; + data?: ApiError; +} + +export type IncomingMessage = + | SuccessfulResponse + | ErrorResponse + | CollectionUpdateMessage; + +export type ApiMethod = ApiCallMethod | ApiJobMethod | ApiEventMethod; + export interface ApiEvent { collection: ApiCallMethod | ApiJobMethod | ApiEventMethod; fields: T; id: number | string; - msg: IncomingApiMessageType.Changed - | IncomingApiMessageType.Added - | IncomingApiMessageType.Removed - | IncomingApiMessageType.NoSub; + msg: CollectionChangeType.Changed + | CollectionChangeType.Added + | CollectionChangeType.Removed; + // TODO: | IncomingApiMessageType.NoSub } export type ApiEventMethod = keyof ApiEventDirectory; @@ -43,10 +62,3 @@ export type ApiEventTyped< M extends ApiEventMethod = ApiEventMethod, T extends ApiEventResponseType = ApiEventResponseType, > = ApiEvent; - -export type IncomingApiMessage = - | PongMessage - | SubscriptionReadyMessage - | ResultMessage - | ConnectedMessage - | ApiEvent; diff --git a/src/app/interfaces/api/api-call-directory.interface.ts b/src/app/interfaces/api/api-call-directory.interface.ts index f2bb22a604f..94a0a065bd4 100644 --- a/src/app/interfaces/api/api-call-directory.interface.ts +++ b/src/app/interfaces/api/api-call-directory.interface.ts @@ -26,6 +26,7 @@ import { Alert, AlertCategory, AlertClasses, AlertClassesUpdate, } from 'app/interfaces/alert.interface'; import { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest } from 'app/interfaces/api-key.interface'; +import { ApiEventMethod } from 'app/interfaces/api-message.interface'; import { App, AppQueryParams, @@ -113,6 +114,7 @@ import { FibreChannelPort, FibreChannelPortChoices, FibreChannelPortUpdate, + FibreChannelStatus, } from 'app/interfaces/fibre-channel.interface'; import { FileRecord, ListdirQueryParams } from 'app/interfaces/file-record.interface'; import { FileSystemStat, Statfs } from 'app/interfaces/filesystem-stat.interface'; @@ -407,11 +409,14 @@ export interface ApiCallDirectory { 'cloudsync.update': { params: [id: number, task: CloudSyncTaskUpdate]; response: CloudSyncTask }; // Core + 'core.ping': { params: void; response: 'pong' }; 'core.download': { params: CoreDownloadQuery; response: CoreDownloadResponse }; 'core.get_jobs': { params: QueryParams; response: Job[] }; 'core.job_abort': { params: [jobId: number]; response: void }; 'core.job_download_logs': { params: [ id: number, filename: string ]; response: string }; 'core.resize_shell': { params: ResizeShellRequest; response: void }; + 'core.subscribe': { params: [name: ApiEventMethod]; response: void }; + 'core.unsubscribe': { params: [id: string]; response: void }; // Cronjob 'cronjob.create': { params: [CronjobUpdate]; response: Cronjob }; @@ -462,6 +467,7 @@ export interface ApiCallDirectory { 'fcport.delete': { params: [id: number]; response: true }; 'fcport.port_choices': { params: [include_used?: boolean]; response: FibreChannelPortChoices }; 'fcport.query': { params: QueryParams; response: FibreChannelPort[] }; + 'fcport.status': { params: []; response: FibreChannelStatus[] }; // Filesystem 'filesystem.acltemplate.by_path': { params: [AclTemplateByPathParams]; response: AclTemplateByPath[] }; diff --git a/src/app/interfaces/api/api-event-directory.interface.ts b/src/app/interfaces/api/api-event-directory.interface.ts index 348198890b4..ef0d1915f11 100644 --- a/src/app/interfaces/api/api-event-directory.interface.ts +++ b/src/app/interfaces/api/api-event-directory.interface.ts @@ -44,7 +44,6 @@ export interface ApiEventDirectory { 'user.query': { response: User }; 'virt.instance.query': { response: VirtualizationInstance }; 'virt.instance.metrics': { response: VirtualizationInstanceMetrics }; - 'virt.instance.agent_running': { response: unknown }; // TODO: Fix type 'vm.query': { response: VirtualMachine }; 'zfs.pool.scan': { response: PoolScan }; 'zfs.snapshot.query': { response: ZfsSnapshot }; diff --git a/src/app/interfaces/credential-type.interface.ts b/src/app/interfaces/credential-type.interface.ts index 0c885dc6206..9e237bdffe9 100644 --- a/src/app/interfaces/credential-type.interface.ts +++ b/src/app/interfaces/credential-type.interface.ts @@ -1,6 +1,7 @@ import { marker as T } from '@biesbjerg/ngx-translate-extract-marker'; export enum CredentialType { + TwoFactor = 'LOGIN_TWOFACTOR', UnixSocket = 'UNIX_SOCKET', RootTcpSocket = 'ROOT_TCP_SOCKET', LoginPassword = 'LOGIN_PASSWORD', @@ -18,6 +19,7 @@ export interface Credentials { } export const credentialTypeLabels = new Map([ + [CredentialType.TwoFactor, T('Two-Factor Authentication')], [CredentialType.UnixSocket, T('Unix Socket')], [CredentialType.RootTcpSocket, T('Root TCP Socket')], [CredentialType.LoginPassword, T('Password Login')], diff --git a/src/app/interfaces/fibre-channel.interface.ts b/src/app/interfaces/fibre-channel.interface.ts index f6daad63336..2ee52c9e805 100644 --- a/src/app/interfaces/fibre-channel.interface.ts +++ b/src/app/interfaces/fibre-channel.interface.ts @@ -3,7 +3,16 @@ export interface FibreChannelPort { port: string; wwpn: string | null; wwpn_b: string | null; - target: unknown; // TODO: Probably IscsiTarget + target: FibreChannelTarget; +} + +export interface FibreChannelTarget { + id: number; + iscsi_target_name: string; + iscsi_target_alias: string | null; + iscsi_target_mode: string; + iscsi_target_auth_networks: string[]; + iscsi_target_rel_tgt_id: number; } export interface FibreChannelPortUpdate { @@ -15,3 +24,19 @@ export type FibreChannelPortChoices = Record; + +export interface FibreChannelStatusNode { + port_type: string; + port_state: string; + speed: string; + physical: boolean; + wwpn?: string; + wwpn_b?: string; + sessions: unknown[]; +} + +export interface FibreChannelStatus { + port: string; + A: FibreChannelStatusNode; + B: FibreChannelStatusNode; +} diff --git a/src/app/interfaces/job.interface.ts b/src/app/interfaces/job.interface.ts index 2b48b3e152c..1cefbdd993a 100644 --- a/src/app/interfaces/job.interface.ts +++ b/src/app/interfaces/job.interface.ts @@ -1,5 +1,5 @@ import { JobState } from 'app/enums/job-state.enum'; -import { ResponseErrorType } from 'app/enums/response-error-type.enum'; +import { JobExceptionType } from 'app/enums/response-error-type.enum'; import { ApiJobMethod } from 'app/interfaces/api/api-job-directory.interface'; import { ApiTimestamp } from 'app/interfaces/api-date.interface'; import { Credentials } from 'app/interfaces/credential-type.interface'; @@ -12,7 +12,7 @@ export interface Job { error: string; extra?: Record; exc_info: { - type?: ResponseErrorType | null; + type?: JobExceptionType | null; extra: string | number | boolean | unknown[] | Record; repr?: string; }; diff --git a/src/app/interfaces/smb-config.interface.ts b/src/app/interfaces/smb-config.interface.ts index 3a05696cd96..be82dd08b78 100644 --- a/src/app/interfaces/smb-config.interface.ts +++ b/src/app/interfaces/smb-config.interface.ts @@ -1,4 +1,3 @@ -import { LogLevel } from 'app/enums/log-level.enum'; import { SmbEncryption } from 'app/enums/smb-encryption.enum'; export interface SmbConfig { @@ -13,10 +12,8 @@ export interface SmbConfig { guest: string; id: number; localmaster: boolean; - loglevel: LogLevel; netbiosalias: string[]; netbiosname: string; - netbiosname_local: string; next_rid: number; ntlmv1_auth: boolean; syslog: boolean; @@ -27,4 +24,4 @@ export interface SmbConfig { export type SmbConfigUpdate = { multichannel?: boolean; -} & Omit; +} & Omit; diff --git a/src/app/modules/alerts/components/alerts-panel/alerts-panel.component.spec.ts b/src/app/modules/alerts/components/alerts-panel/alerts-panel.component.spec.ts index 8d63cd62679..dd80c2946e3 100644 --- a/src/app/modules/alerts/components/alerts-panel/alerts-panel.component.spec.ts +++ b/src/app/modules/alerts/components/alerts-panel/alerts-panel.component.spec.ts @@ -7,7 +7,7 @@ import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils'; import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; import { NavigateAndInteractDirective } from 'app/directives/navigate-and-interact/navigate-and-interact.directive'; import { AlertLevel } from 'app/enums/alert-level.enum'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { Alert } from 'app/interfaces/alert.interface'; import { AlertComponent } from 'app/modules/alerts/components/alert/alert.component'; import { AlertsPanelComponent } from 'app/modules/alerts/components/alerts-panel/alerts-panel.component'; @@ -158,7 +158,7 @@ describe('AlertsPanelComponent', () => { const websocketMock = spectator.inject(MockApiService); websocketMock.emitSubscribeEvent({ - msg: IncomingApiMessageType.Added, + msg: CollectionChangeType.Added, collection: 'alert.list', fields: { id: 'new', @@ -177,7 +177,7 @@ describe('AlertsPanelComponent', () => { const websocketMock = spectator.inject(MockApiService); websocketMock.emitSubscribeEvent({ - msg: IncomingApiMessageType.Changed, + msg: CollectionChangeType.Changed, collection: 'alert.list', fields: { id: '1', diff --git a/src/app/modules/alerts/store/alert.effects.ts b/src/app/modules/alerts/store/alert.effects.ts index 2e735fa6756..531f0198675 100644 --- a/src/app/modules/alerts/store/alert.effects.ts +++ b/src/app/modules/alerts/store/alert.effects.ts @@ -8,7 +8,7 @@ import { import { catchError, map, mergeMap, pairwise, switchMap, withLatestFrom, } from 'rxjs/operators'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { Alert } from 'app/interfaces/alert.interface'; import { dismissAlertPressed, dismissAllAlertsPressed, @@ -37,7 +37,7 @@ export class AlertEffects { switchMap(() => { return this.api.call('alert.list').pipe( map((alerts) => alertsLoaded({ alerts })), - catchError((error) => { + catchError((error: unknown) => { console.error(error); // TODO: See if it would make sense to parse middleware error. return of(alertsNotLoaded({ @@ -57,14 +57,14 @@ export class AlertEffects { switchMap((isAlertsPanelOpen) => { switch (true) { case [ - IncomingApiMessageType.Added, IncomingApiMessageType.Changed, + CollectionChangeType.Added, CollectionChangeType.Changed, ].includes(event.msg) && isAlertsPanelOpen: return of(alertReceivedWhenPanelIsOpen()); - case event.msg === IncomingApiMessageType.Added && !isAlertsPanelOpen: + case event.msg === CollectionChangeType.Added && !isAlertsPanelOpen: return of(alertAdded({ alert: event.fields })); - case event.msg === IncomingApiMessageType.Changed && !isAlertsPanelOpen: + case event.msg === CollectionChangeType.Changed && !isAlertsPanelOpen: return of(alertChanged({ alert: event.fields })); - case event.msg === IncomingApiMessageType.Removed: + case event.msg === CollectionChangeType.Removed: return of(alertRemoved({ id: event.id.toString() })); default: return EMPTY; @@ -81,7 +81,7 @@ export class AlertEffects { ofType(dismissAlertPressed), mergeMap(({ id }) => { return this.api.call('alert.dismiss', [id]).pipe( - catchError((error) => { + catchError((error: unknown) => { this.errorHandler.showErrorModal(error); this.store$.dispatch(alertChanged({ alert: { id, dismissed: false } as Alert })); return of(EMPTY); @@ -94,7 +94,7 @@ export class AlertEffects { ofType(reopenAlertPressed), mergeMap(({ id }) => { return this.api.call('alert.restore', [id]).pipe( - catchError((error) => { + catchError((error: unknown) => { this.errorHandler.showErrorModal(error); this.store$.dispatch(alertChanged({ alert: { id, dismissed: true } as Alert })); return of(EMPTY); @@ -109,7 +109,7 @@ export class AlertEffects { mergeMap(([, [unreadAlerts]]) => { const requests = unreadAlerts.map((alert) => this.api.call('alert.dismiss', [alert.id])); return forkJoin(requests).pipe( - catchError((error) => { + catchError((error: unknown) => { this.errorHandler.showErrorModal(error); this.store$.dispatch(alertsDismissedChanged({ dismissed: false })); return of(EMPTY); @@ -125,7 +125,7 @@ export class AlertEffects { mergeMap(([, [dismissedAlerts]]) => { const requests = dismissedAlerts.map((alert) => this.api.call('alert.restore', [alert.id])); return forkJoin(requests).pipe( - catchError((error) => { + catchError((error: unknown) => { this.errorHandler.showErrorModal(error); this.store$.dispatch(alertsDismissedChanged({ dismissed: true })); return of(EMPTY); diff --git a/src/app/modules/buttons/export-button/export-button.component.ts b/src/app/modules/buttons/export-button/export-button.component.ts index b8eb4a5a438..ae779a75f3f 100644 --- a/src/app/modules/buttons/export-button/export-button.component.ts +++ b/src/app/modules/buttons/export-button/export-button.component.ts @@ -93,7 +93,7 @@ export class ExportButtonComponent { return this.api.call('core.download', [downloadMethod, [customArguments], url]); }), switchMap(([, url]) => this.download.downloadUrl(url, `${this.filename()}.${this.fileType()}`, this.fileMimeType())), - catchError((error) => { + catchError((error: unknown) => { this.isLoading = false; this.cdr.markForCheck(); this.dialogService.error(this.errorHandler.parseError(error)); diff --git a/src/app/modules/charts/gauge-chart/gauge-chart.component.ts b/src/app/modules/charts/gauge-chart/gauge-chart.component.ts index af2e02d648b..c3cd8426db7 100644 --- a/src/app/modules/charts/gauge-chart/gauge-chart.component.ts +++ b/src/app/modules/charts/gauge-chart/gauge-chart.component.ts @@ -1,5 +1,4 @@ import { - Input, Component, HostBinding, ChangeDetectionStrategy, input, computed, } from '@angular/core'; @@ -46,8 +45,11 @@ export class GaugeChartComponent { }, }); - @Input() @HostBinding('style.height.px') height = defaultHeight; - @Input() @HostBinding('style.width.px') width = defaultWidth; + readonly height = input(defaultHeight); + readonly width = input(defaultWidth); + + @HostBinding('style.height.px') get heightStyle(): number { return this.height(); } + @HostBinding('style.width.px') get widthStyle(): number { return this.width(); } chartOptions: ChartOptions<'doughnut'> = { responsive: true, diff --git a/src/app/modules/dialog/components/error-dialog/error-dialog.component.ts b/src/app/modules/dialog/components/error-dialog/error-dialog.component.ts index e0f51881969..8278a954cf3 100644 --- a/src/app/modules/dialog/components/error-dialog/error-dialog.component.ts +++ b/src/app/modules/dialog/components/error-dialog/error-dialog.component.ts @@ -1,4 +1,3 @@ -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, ElementRef, viewChild, } from '@angular/core'; @@ -71,15 +70,15 @@ export class ErrorDialogComponent { this.dialogRef.close(); } }, - error: (err: HttpErrorResponse) => { + error: (err: unknown) => { if (this.dialogRef) { this.dialogRef.close(); } - this.dialogService.error(this.errorHandler.parseHttpError(err)); + this.dialogService.error(this.errorHandler.parseError(err)); }, }); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/modules/dialog/components/general-dialog/general-dialog.component.ts b/src/app/modules/dialog/components/general-dialog/general-dialog.component.ts index 53adfab64cd..751af57604c 100644 --- a/src/app/modules/dialog/components/general-dialog/general-dialog.component.ts +++ b/src/app/modules/dialog/components/general-dialog/general-dialog.component.ts @@ -1,7 +1,9 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, Component, Inject, +} from '@angular/core'; import { MatButton } from '@angular/material/button'; import { - MatDialogRef, MatDialogTitle, MatDialogContent, MatDialogActions, + MatDialogRef, MatDialogTitle, MatDialogContent, MatDialogActions, MAT_DIALOG_DATA, } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; import { MarkedIcon } from 'app/modules/ix-icon/icon-marker.util'; @@ -42,9 +44,8 @@ export interface GeneralDialogConfig { ], }) export class GeneralDialogComponent { - @Input() conf: GeneralDialogConfig; - constructor( - public dialogRef: MatDialogRef, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public conf: GeneralDialogConfig, ) { } } diff --git a/src/app/modules/dialog/components/multi-error-dialog/error-template/error-template.component.ts b/src/app/modules/dialog/components/multi-error-dialog/error-template/error-template.component.ts index da6a58004ae..c99bfafe26e 100644 --- a/src/app/modules/dialog/components/multi-error-dialog/error-template/error-template.component.ts +++ b/src/app/modules/dialog/components/multi-error-dialog/error-template/error-template.component.ts @@ -1,4 +1,3 @@ -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, ElementRef, input, viewChild, } from '@angular/core'; @@ -77,8 +76,8 @@ export class ErrorTemplateComponent { next: (file) => { this.download.downloadBlob(file, `${this.logs().id}.log`); }, - error: (error: HttpErrorResponse) => { - this.dialogService.error(this.errorHandler.parseHttpError(error)); + error: (error: unknown) => { + this.dialogService.error(this.errorHandler.parseError(error)); }, }); }); diff --git a/src/app/modules/dialog/components/show-logs-dialog/show-logs-dialog.component.ts b/src/app/modules/dialog/components/show-logs-dialog/show-logs-dialog.component.ts index 0e59615631a..9723efc0893 100644 --- a/src/app/modules/dialog/components/show-logs-dialog/show-logs-dialog.component.ts +++ b/src/app/modules/dialog/components/show-logs-dialog/show-logs-dialog.component.ts @@ -1,4 +1,3 @@ -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { @@ -44,7 +43,7 @@ export class ShowLogsDialogComponent { downloadLogs(): void { this.api.call('core.job_download_logs', [this.job.id, `${this.job.id}.log`]).pipe( switchMap((url) => this.download.downloadUrl(url, `${this.job.id}.log`, 'text/plain')), - catchError((error: HttpErrorResponse | Job) => { + catchError((error: unknown) => { this.dialogService.error(this.errorHandler.parseError(error)); return EMPTY; }), diff --git a/src/app/modules/dialog/dialog.service.ts b/src/app/modules/dialog/dialog.service.ts index 152a4b98723..76e67838c88 100644 --- a/src/app/modules/dialog/dialog.service.ts +++ b/src/app/modules/dialog/dialog.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; +import { MatDialog } from '@angular/material/dialog'; import { UntilDestroy } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { Observable, of } from 'rxjs'; @@ -100,9 +100,10 @@ export class DialogService { return dialogRef.afterClosed(); } - generalDialog(conf: GeneralDialogConfig, matConfig?: MatDialogConfig): Observable { - const dialogRef = this.matDialog.open(GeneralDialogComponent, matConfig); - dialogRef.componentInstance.conf = conf; + generalDialog(conf: GeneralDialogConfig): Observable { + const dialogRef = this.matDialog.open(GeneralDialogComponent, { + data: conf, + }); return dialogRef.afterClosed(); } diff --git a/src/app/modules/feedback/components/file-ticket-licensed/file-ticket-licensed.component.ts b/src/app/modules/feedback/components/file-ticket-licensed/file-ticket-licensed.component.ts index fb57eb49b1f..85c967e5e1d 100644 --- a/src/app/modules/feedback/components/file-ticket-licensed/file-ticket-licensed.component.ts +++ b/src/app/modules/feedback/components/file-ticket-licensed/file-ticket-licensed.component.ts @@ -142,7 +142,7 @@ export class FileTicketLicensedComponent { untilDestroyed(this), ).subscribe({ next: (createdTicket) => this.onSuccess(createdTicket.url), - error: (error) => this.formErrorHandler.handleValidationErrors(error, this.form), + error: (error: unknown) => this.formErrorHandler.handleValidationErrors(error, this.form), }); } diff --git a/src/app/modules/feedback/components/file-ticket/file-ticket.component.ts b/src/app/modules/feedback/components/file-ticket/file-ticket.component.ts index ae23a834cfb..5fd761f14e8 100644 --- a/src/app/modules/feedback/components/file-ticket/file-ticket.component.ts +++ b/src/app/modules/feedback/components/file-ticket/file-ticket.component.ts @@ -94,7 +94,7 @@ export class FileTicketComponent { untilDestroyed(this), ).subscribe({ next: (createdTicket) => this.onSuccess(createdTicket.url), - error: (error) => this.formErrorHandler.handleValidationErrors(error, this.form), + error: (error: unknown) => this.formErrorHandler.handleValidationErrors(error, this.form), }); } diff --git a/src/app/modules/forms/custom-selects/cloud-credentials-select/cloud-credentials-select.component.html b/src/app/modules/forms/custom-selects/cloud-credentials-select/cloud-credentials-select.component.html index 0c278a2facb..1f3fef6e1eb 100644 --- a/src/app/modules/forms/custom-selects/cloud-credentials-select/cloud-credentials-select.component.html +++ b/src/app/modules/forms/custom-selects/cloud-credentials-select/cloud-credentials-select.component.html @@ -2,4 +2,4 @@ [label]="label()" [tooltip]="tooltip()" [required]="required()" -> \ No newline at end of file +> diff --git a/src/app/modules/forms/custom-selects/cloud-credentials-select/cloud-credentials-select.component.ts b/src/app/modules/forms/custom-selects/cloud-credentials-select/cloud-credentials-select.component.ts index ad7a540adb0..7408a316184 100644 --- a/src/app/modules/forms/custom-selects/cloud-credentials-select/cloud-credentials-select.component.ts +++ b/src/app/modules/forms/custom-selects/cloud-credentials-select/cloud-credentials-select.component.ts @@ -1,7 +1,6 @@ import { ComponentType } from '@angular/cdk/portal'; import { - Component, forwardRef, inject, ChangeDetectionStrategy, - input, + Component, forwardRef, inject, ChangeDetectionStrategy, input, } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { Observable, map } from 'rxjs'; @@ -29,10 +28,10 @@ import { CloudCredentialService } from 'app/services/cloud-credential.service'; imports: [IxSelectComponent], }) export class CloudCredentialsSelectComponent extends IxSelectWithNewOption { - readonly label = input(undefined); - readonly tooltip = input(undefined); - readonly required = input(undefined); - readonly filterByProviders = input(undefined); + readonly label = input(); + readonly tooltip = input(); + readonly required = input(); + readonly filterByProviders = input(); private cloudCredentialService = inject(CloudCredentialService); @@ -58,7 +57,6 @@ export class CloudCredentialsSelectComponent extends IxSelectWithNewOption { } override getFormInputData(): { providers: CloudSyncProviderName[] } { - const filterByProviders = this.filterByProviders(); - return filterByProviders?.length ? { providers: filterByProviders } : undefined; + return this.filterByProviders()?.length ? { providers: this.filterByProviders() } : undefined; } } diff --git a/src/app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component.html b/src/app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component.html index 0c278a2facb..1f3fef6e1eb 100644 --- a/src/app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component.html +++ b/src/app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component.html @@ -2,4 +2,4 @@ [label]="label()" [tooltip]="tooltip()" [required]="required()" -> \ No newline at end of file +> diff --git a/src/app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component.ts b/src/app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component.ts index 8e8b7f9aa06..e1dac7b36b5 100644 --- a/src/app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component.ts +++ b/src/app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component.ts @@ -1,8 +1,7 @@ import { ComponentType } from '@angular/cdk/portal'; import { ChangeDetectionStrategy, - Component, forwardRef, inject, - input, + Component, forwardRef, inject, input, } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { Observable } from 'rxjs'; @@ -30,9 +29,9 @@ import { KeychainCredentialService } from 'app/services/keychain-credential.serv imports: [IxSelectComponent], }) export class SshCredentialsSelectComponent extends IxSelectWithNewOption { - readonly label = input(undefined); - readonly tooltip = input(undefined); - readonly required = input(undefined); + readonly label = input(); + readonly tooltip = input(); + readonly required = input(); private keychainCredentialsService = inject(KeychainCredentialService); diff --git a/src/app/modules/forms/ix-dynamic-form/components/ix-dynamic-form/ix-dynamic-form-item/ix-dynamic-form-item.component.ts b/src/app/modules/forms/ix-dynamic-form/components/ix-dynamic-form/ix-dynamic-form-item/ix-dynamic-form-item.component.ts index d443755730c..4b1ef65866f 100644 --- a/src/app/modules/forms/ix-dynamic-form/components/ix-dynamic-form/ix-dynamic-form-item/ix-dynamic-form-item.component.ts +++ b/src/app/modules/forms/ix-dynamic-form/components/ix-dynamic-form/ix-dynamic-form-item/ix-dynamic-form-item.component.ts @@ -1,7 +1,6 @@ import { AsyncPipe } from '@angular/common'; import { - ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit, output, - input, + ChangeDetectionStrategy, ChangeDetectorRef, Component, input, Input, OnInit, output, } from '@angular/core'; import { UntypedFormArray, UntypedFormGroup, ReactiveFormsModule } from '@angular/forms'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; @@ -57,9 +56,9 @@ import { TooltipComponent } from 'app/modules/tooltip/tooltip.component'; ], }) export class IxDynamicFormItemComponent implements OnInit { - readonly dynamicForm = input(undefined); + readonly dynamicForm = input(); @Input() dynamicSchema: DynamicFormSchemaNode; - readonly isEditMode = input(undefined); + readonly isEditMode = input(); readonly addListItem = output(); readonly deleteListItem = output(); @@ -91,16 +90,15 @@ export class IxDynamicFormItemComponent implements OnInit { this.changeDetectorRef.markForCheck(); }); }); - const dynamicForm = this.dynamicForm(); if ( this.dynamicSchema?.editable !== undefined && !this.dynamicSchema?.editable ) { - dynamicForm?.get(this.dynamicSchema.controlName)?.disable(); + this.dynamicForm()?.get(this.dynamicSchema.controlName)?.disable(); } if (this.dynamicSchema?.hidden) { - (dynamicForm.controls[this.dynamicSchema.controlName] as CustomUntypedFormField)?.hidden$?.next(true); + (this.dynamicForm().controls[this.dynamicSchema.controlName] as CustomUntypedFormField)?.hidden$?.next(true); } } diff --git a/src/app/modules/forms/ix-forms/components/ix-button-group/ix-button-group.component.html b/src/app/modules/forms/ix-forms/components/ix-button-group/ix-button-group.component.html index f176b3b2303..447c1b70301 100644 --- a/src/app/modules/forms/ix-forms/components/ix-button-group/ix-button-group.component.html +++ b/src/app/modules/forms/ix-forms/components/ix-button-group/ix-button-group.component.html @@ -1,7 +1,7 @@ -@if (label || tooltip) { +@if (label() || tooltip()) { @@ -13,7 +13,7 @@ [disabled]="isDisabled" [ixTest]="controlDirective.name" [vertical]="vertical()" - [attr.aria-label]="label" + [attr.aria-label]="label()" (change)="onValueChanged($event)" > @for (option of options() | async; track option.label) { @@ -27,7 +27,7 @@ } - -@if (hint) { - {{ hint }} + +@if (hint()) { + {{ hint() }} } diff --git a/src/app/modules/forms/ix-forms/components/ix-button-group/ix-button-group.component.ts b/src/app/modules/forms/ix-forms/components/ix-button-group/ix-button-group.component.ts index f82bd5bc5e6..6baf554a535 100644 --- a/src/app/modules/forms/ix-forms/components/ix-button-group/ix-button-group.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-button-group/ix-button-group.component.ts @@ -1,7 +1,6 @@ import { AsyncPipe } from '@angular/common'; import { - ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, - input, + ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, input, } from '@angular/core'; import { ControlValueAccessor, NgControl } from '@angular/forms'; import { MatButtonToggleChange, MatButtonToggleGroup, MatButtonToggle } from '@angular/material/button-toggle'; @@ -11,6 +10,7 @@ import { Observable } from 'rxjs'; import { Option } from 'app/interfaces/option.interface'; import { IxErrorsComponent } from 'app/modules/forms/ix-forms/components/ix-errors/ix-errors.component'; import { IxLabelComponent } from 'app/modules/forms/ix-forms/components/ix-label/ix-label.component'; +import { RegisteredControlDirective } from 'app/modules/forms/ix-forms/directives/registered-control.directive'; import { TestOverrideDirective } from 'app/modules/test-id/test-override/test-override.directive'; import { TestDirective } from 'app/modules/test-id/test.directive'; @@ -28,19 +28,24 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; MatHint, AsyncPipe, TranslateModule, - TestOverrideDirective, TestDirective, + TestOverrideDirective, + RegisteredControlDirective, ], }) export class IxButtonGroupComponent implements ControlValueAccessor { - @Input() label: string; - @Input() hint: string; - @Input() tooltip: string; - readonly required = input(false); - readonly options = input | undefined>(undefined); + readonly label = input(); + readonly hint = input(); + readonly tooltip = input(); + readonly required = input(); + readonly options = input>(); readonly vertical = input(false); + readonly inlineFields = input(false); + @HostBinding('class.inlineFields') - @Input() inlineFields = false; + get inlineFieldsClass(): boolean { + return this.inlineFields(); + } isDisabled = false; value: string; diff --git a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html index 7f5f168e889..d787804966d 100644 --- a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html +++ b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.html @@ -1,8 +1,8 @@
- @if (label || tooltip) { + @if (label() || tooltip()) { @@ -16,7 +16,7 @@ [value]="selectedOption?.label || textContent" [placeholder]="allowCustomValue() ? ('Search or enter value' | translate) : ('Search' | translate)" [disabled]="isDisabled" - [attr.aria-label]="label" + [attr.aria-label]="label()" [matAutocomplete]="auto" [ixTest]="controlDirective.name" [class.has-value]="selectedOption?.label || textContent" @@ -76,9 +76,9 @@
- + - @if (hint) { - {{ hint }} + @if (hint()) { + {{ hint() }} } diff --git a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts index eb2d5745e51..6c75b691266 100644 --- a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts @@ -1,8 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, + Component, computed, ElementRef, - Input, OnInit, viewChild, input, @@ -30,6 +29,7 @@ import { Option } from 'app/interfaces/option.interface'; import { IxComboboxProvider, IxComboboxProviderManager } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox-provider'; import { IxErrorsComponent } from 'app/modules/forms/ix-forms/components/ix-errors/ix-errors.component'; import { IxLabelComponent } from 'app/modules/forms/ix-forms/components/ix-label/ix-label.component'; +import { RegisteredControlDirective } from 'app/modules/forms/ix-forms/directives/registered-control.directive'; import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; import { TestOverrideDirective } from 'app/modules/test-id/test-override/test-override.directive'; import { TestDirective } from 'app/modules/test-id/test.directive'; @@ -54,20 +54,21 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; TranslateModule, TestOverrideDirective, TestDirective, + RegisteredControlDirective, ], }) export class IxComboboxComponent implements ControlValueAccessor, OnInit { - @Input() label: string; - @Input() hint: string; - readonly required = input(undefined); - @Input() tooltip: string; + readonly label = input(); + readonly hint = input(); + readonly required = input(); + readonly tooltip = input(); readonly allowCustomValue = input(false); - @Input() set provider(comboboxProvider: IxComboboxProvider) { - this.comboboxProviderHandler = new IxComboboxProviderManager(comboboxProvider); - this.cdr.markForCheck(); - } - private comboboxProviderHandler: IxComboboxProviderManager; + readonly provider = input.required(); + + private comboboxProviderHandler = computed(() => { + return new IxComboboxProviderManager(this.provider()); + }); readonly inputElementRef = viewChild>('ixInput'); readonly autoCompleteRef = viewChild('auto'); @@ -136,7 +137,7 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit { this.loading = true; this.cdr.markForCheck(); - this.comboboxProviderHandler?.fetch(filterValue).pipe( + this.comboboxProviderHandler()?.fetch(filterValue).pipe( catchError(() => { this.hasErrorInOptions = true; return EMPTY; @@ -194,7 +195,7 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit { this.loading = true; this.cdr.markForCheck(); - this.comboboxProviderHandler?.nextPage(this.filterValue !== null || this.filterValue !== undefined ? this.filterValue : '') + this.comboboxProviderHandler()?.nextPage(this.filterValue !== null || this.filterValue !== undefined ? this.filterValue : '') .pipe(untilDestroyed(this)).subscribe((options: Option[]) => { this.loading = false; this.cdr.markForCheck(); diff --git a/src/app/modules/forms/ix-forms/components/ix-explorer/create-dataset-dialog/create-dataset-dialog.component.ts b/src/app/modules/forms/ix-forms/components/ix-explorer/create-dataset-dialog/create-dataset-dialog.component.ts index c2db493f268..035c22e5090 100644 --- a/src/app/modules/forms/ix-forms/components/ix-explorer/create-dataset-dialog/create-dataset-dialog.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-explorer/create-dataset-dialog/create-dataset-dialog.component.ts @@ -92,7 +92,7 @@ export class CreateDatasetDialogComponent implements OnInit { this.isLoading$.next(false); this.dialogRef.close(dataset); }, - error: (error) => { + error: (error: unknown) => { this.isLoading$.next(false); this.dialog.error(this.errorHandler.parseError(error)); }, @@ -116,7 +116,7 @@ export class CreateDatasetDialogComponent implements OnInit { this.cdr.markForCheck(); this.addNameValidators(); }, - error: (error) => { + error: (error: unknown) => { this.isLoading$.next(false); this.dialog.error(this.errorHandler.parseError(error)); this.dialogRef.close(false); diff --git a/src/app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component.ts b/src/app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component.ts index 5da7d59aa90..0fc22f5336b 100644 --- a/src/app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component.ts @@ -22,7 +22,6 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r import { ExplorerNodeType } from 'app/enums/explorer-type.enum'; import { mntPath } from 'app/enums/mnt-path.enum'; import { Role } from 'app/enums/role.enum'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { Dataset, DatasetCreate } from 'app/interfaces/dataset.interface'; import { IxSimpleChanges } from 'app/interfaces/simple-changes.interface'; import { ExplorerNodeData, TreeNode } from 'app/interfaces/tree-node.interface'; @@ -34,6 +33,7 @@ import { RegisteredControlDirective } from 'app/modules/forms/ix-forms/directive import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; import { TestOverrideDirective } from 'app/modules/test-id/test-override/test-override.directive'; import { TestDirective } from 'app/modules/test-id/test.directive'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; @UntilDestroy() @Component({ @@ -120,6 +120,7 @@ export class IxExplorerComponent implements OnInit, OnChanges, ControlValueAcces private cdr: ChangeDetectorRef, private matDialog: MatDialog, private translate: TranslateService, + private errorHandler: ErrorHandlerService, ) { this.controlDirective.valueAccessor = this; } @@ -292,8 +293,8 @@ export class IxExplorerComponent implements OnInit, OnChanges, ControlValueAcces } return this.nodeProvider()(node).pipe( - catchError((error: ApiError | Error) => { - this.loadingError = 'reason' in error ? error.reason : error.message; + catchError((error: unknown) => { + this.loadingError = this.errorHandler.getFirstErrorMessage(error); this.cdr.markForCheck(); return of([]); }), diff --git a/src/app/modules/forms/ix-forms/components/ix-select/ix-select-with-new-option.directive.ts b/src/app/modules/forms/ix-forms/components/ix-select/ix-select-with-new-option.directive.ts index c236fc1969f..ca5dfc2666c 100644 --- a/src/app/modules/forms/ix-forms/components/ix-select/ix-select-with-new-option.directive.ts +++ b/src/app/modules/forms/ix-forms/components/ix-select/ix-select-with-new-option.directive.ts @@ -1,8 +1,6 @@ import { ComponentType } from '@angular/cdk/portal'; import { - AfterViewInit, Directive, OnInit, inject, - viewChild, - input, + AfterViewInit, Directive, OnInit, viewChild, inject, } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; @@ -19,7 +17,6 @@ export const addNewIxSelectValue = 'ADD_NEW'; @UntilDestroy() @Directive() export abstract class IxSelectWithNewOption implements OnInit, AfterViewInit { - readonly disabled = input(undefined); formComponentIsWide = false; readonly ixSelect = viewChild(IxSelectComponent); diff --git a/src/app/modules/forms/ix-forms/services/form-error-handler.service.spec.ts b/src/app/modules/forms/ix-forms/services/form-error-handler.service.spec.ts index 430bf271c1f..b5941ab0464 100644 --- a/src/app/modules/forms/ix-forms/services/form-error-handler.service.spec.ts +++ b/src/app/modules/forms/ix-forms/services/form-error-handler.service.spec.ts @@ -1,34 +1,41 @@ import { fakeAsync, tick } from '@angular/core/testing'; import { FormControl, FormGroup } from '@ngneat/reactive-forms'; -import { SpectatorService, createServiceFactory, mockProvider } from '@ngneat/spectator/jest'; -import { ApiErrorName } from 'app/enums/api-error-name.enum'; -import { ResponseErrorType } from 'app/enums/response-error-type.enum'; +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { ApiErrorName } from 'app/enums/api.enum'; +import { JobState } from 'app/enums/job-state.enum'; +import { JobExceptionType } from 'app/enums/response-error-type.enum'; import { ApiError } from 'app/interfaces/api-error.interface'; +import { ErrorResponse } from 'app/interfaces/api-message.interface'; import { ErrorReport } from 'app/interfaces/error-report.interface'; +import { Job } from 'app/interfaces/job.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxFormService } from 'app/modules/forms/ix-forms/services/ix-form.service'; import { ErrorHandlerService } from 'app/services/error-handler.service'; -const fakeError: ApiError = { - type: ResponseErrorType.Validation, - error: 11, - errname: ApiErrorName.Again, - extra: [ - [ - 'test-query.test_control_1', - 'Error string for control 1', - 22, - ], - [ - 'test-query.test_control_2', - 'Error string for control 2', - 22, - ], - ], - trace: { class: 'ValidationErrors', formatted: 'Formatted string', frames: [] }, - reason: 'Test reason', -}; +const errorResponse = { + jsonrpc: '2.0', + error: { + data: { + error: 11, + errname: ApiErrorName.Validation, + extra: [ + [ + 'test-query.test_control_1', + 'Error string for control 1', + 22, + ], + [ + 'test-query.test_control_2', + 'Error string for control 2', + 22, + ], + ], + trace: { class: 'ValidationErrors', formatted: 'Formatted string', frames: [] }, + reason: 'Test reason', + }, + }, +} as ErrorResponse; const formGroup = new FormGroup({ test_control_1: new FormControl(''), @@ -42,9 +49,8 @@ describe('FormErrorHandlerService', () => { providers: [ mockProvider(DialogService), mockProvider(ErrorHandlerService, { - isWebSocketError: jest.fn(() => true), parseError: jest.fn((error: ApiError) => ({ - title: error.type, + title: 'Error', message: error.reason, backtrace: error.trace?.formatted, } as ErrorReport)), @@ -61,11 +67,11 @@ describe('FormErrorHandlerService', () => { }); describe('handleValidationErrors', () => { - it('sets errors for controls', () => { + it('sets errors for controls for a call validation error', () => { jest.spyOn(formGroup.controls.test_control_1, 'setErrors').mockImplementation(); jest.spyOn(formGroup.controls.test_control_1, 'markAsTouched').mockImplementation(); - spectator.service.handleValidationErrors(fakeError, formGroup); + spectator.service.handleValidationErrors(errorResponse, formGroup); expect(formGroup.controls.test_control_1.setErrors).toHaveBeenCalledWith({ ixManualValidateError: { @@ -77,15 +83,46 @@ describe('FormErrorHandlerService', () => { expect(formGroup.controls.test_control_1.markAsTouched).toHaveBeenCalled(); }); + it('sets errors for a job failed with validation errors', () => { + jest.spyOn(formGroup.controls.test_control_1, 'setErrors').mockImplementation(); + jest.spyOn(formGroup.controls.test_control_1, 'markAsTouched').mockImplementation(); + + const failedJob = { + state: JobState.Failed, + error: '[EINVAL] Value error, Not a valid integer', + exception: '', + exc_info: { + type: JobExceptionType.Validation, + extra: [ + [ + 'test-query.test_control_1', + 'Value error, Not a valid integer', + 22, + ], + ], + }, + } as Job; + spectator.service.handleValidationErrors(failedJob, formGroup); + + expect(formGroup.controls.test_control_1.setErrors).toHaveBeenCalledWith({ + ixManualValidateError: { + message: 'Value error, Not a valid integer', + }, + manualValidateError: true, + manualValidateErrorMsg: 'Value error, Not a valid integer', + }); + expect(formGroup.controls.test_control_1.markAsTouched).toHaveBeenCalled(); + }); + it('shows error dialog and error message in logs when control is not found', () => { - spectator.service.handleValidationErrors(fakeError, formGroup); + spectator.service.handleValidationErrors(errorResponse, formGroup); expect(console.error).not.toHaveBeenCalledWith('Could not find control test_control_1.'); expect(console.error).toHaveBeenCalledWith('Could not find control test_control_2.'); expect(spectator.inject(DialogService).error).toHaveBeenCalledWith({ - title: fakeError.type, - message: fakeError.reason, - backtrace: fakeError.trace.formatted, + title: 'Error', + message: errorResponse.error.data.reason, + backtrace: errorResponse.error.data.trace.formatted, }); }); @@ -96,7 +133,7 @@ describe('FormErrorHandlerService', () => { } as unknown as HTMLElement; jest.spyOn(spectator.inject(IxFormService), 'getElementByControlName').mockReturnValue(elementMock); - spectator.service.handleValidationErrors(fakeError, formGroup); + spectator.service.handleValidationErrors(errorResponse, formGroup); tick(); diff --git a/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts b/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts index 4e159a932e6..f22dcae057e 100644 --- a/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts +++ b/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts @@ -1,7 +1,9 @@ import { DOCUMENT } from '@angular/common'; import { Inject, Injectable } from '@angular/core'; import { AbstractControl, UntypedFormArray, UntypedFormGroup } from '@angular/forms'; -import { ResponseErrorType } from 'app/enums/response-error-type.enum'; +import { ApiErrorName } from 'app/enums/api.enum'; +import { JobExceptionType } from 'app/enums/response-error-type.enum'; +import { isApiError, isErrorResponse, isFailedJob } from 'app/helpers/api.helper'; import { ApiError } from 'app/interfaces/api-error.interface'; import { Job } from 'app/interfaces/job.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; @@ -32,14 +34,18 @@ export class FormErrorHandlerService { fieldsMap: Record = {}, triggerAnchor: string = undefined, ): void { - if (this.errorHandler.isWebSocketError(error) && error.type === ResponseErrorType.Validation && error.extra) { - this.handleValidationError(error, formGroup, fieldsMap, triggerAnchor); + const isValidationError = isErrorResponse(error) + && isApiError(error.error.data) + && error.error.data.errname === ApiErrorName.Validation + && error.error.data.extra; + if (isValidationError) { + this.handleValidationError(error.error.data, formGroup, fieldsMap, triggerAnchor); return; } if ( - this.errorHandler.isJobError(error) - && error.exc_info.type === ResponseErrorType.Validation + isFailedJob(error) + && error.exc_info.type === JobExceptionType.Validation && error.exc_info.extra ) { this.handleValidationError( diff --git a/src/app/modules/forms/ix-forms/services/ix-validators.service.ts b/src/app/modules/forms/ix-forms/services/ix-validators.service.ts index 46d863aaff7..6bb4c2e0f34 100644 --- a/src/app/modules/forms/ix-forms/services/ix-validators.service.ts +++ b/src/app/modules/forms/ix-forms/services/ix-validators.service.ts @@ -3,7 +3,7 @@ import { AbstractControl, FormControl, ValidationErrors, ValidatorFn, Validators, } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; -import isCidr from 'is-cidr'; +import * as isCidr from 'is-cidr'; @Injectable({ providedIn: 'root', diff --git a/src/app/modules/forms/ix-forms/validators/forbidden-values-validation/forbidden-values-validation.ts b/src/app/modules/forms/ix-forms/validators/forbidden-values-validation/forbidden-values-validation.ts index 5b5318d3de9..4506dbc74af 100644 --- a/src/app/modules/forms/ix-forms/validators/forbidden-values-validation/forbidden-values-validation.ts +++ b/src/app/modules/forms/ix-forms/validators/forbidden-values-validation/forbidden-values-validation.ts @@ -22,7 +22,7 @@ export function forbiddenAsyncValues( ): AsyncValidatorFn { const request$ = arrayOfValues$.pipe( shareReplay({ refCount: false, bufferSize: 1 }), - catchError((error) => { + catchError((error: unknown) => { console.error(error); return of(null); }), diff --git a/src/app/modules/forms/ix-forms/validators/image-validator/image-validator.service.ts b/src/app/modules/forms/ix-forms/validators/image-validator/image-validator.service.ts index b8c15886a4d..4b5acc272de 100644 --- a/src/app/modules/forms/ix-forms/validators/image-validator/image-validator.service.ts +++ b/src/app/modules/forms/ix-forms/validators/image-validator/image-validator.service.ts @@ -43,7 +43,7 @@ export class ImageValidatorService { take(screenshots.length), concatMap((file: File): Observable => { return this.validateImage(file, sizeLimitBytes).pipe( - catchError((error: ValidatedFile) => of(error)), + catchError((error: unknown) => of(error as ValidatedFile)), ); }), toArray(), diff --git a/src/app/modules/forms/ix-forms/validators/ip-validation.ts b/src/app/modules/forms/ix-forms/validators/ip-validation.ts index 016133ae17a..40118ebdd69 100644 --- a/src/app/modules/forms/ix-forms/validators/ip-validation.ts +++ b/src/app/modules/forms/ix-forms/validators/ip-validation.ts @@ -1,6 +1,6 @@ import { FormControl, ValidatorFn } from '@angular/forms'; import ipRegex from 'ip-regex'; -import isCidr from 'is-cidr'; +import * as isCidr from 'is-cidr'; import { indexOf } from 'lodash-es'; // Accepts ipv4 or ipv6 addresses with no CIDR (ie, /24) diff --git a/src/app/modules/forms/search-input/components/basic-search/basic-search.component.ts b/src/app/modules/forms/search-input/components/basic-search/basic-search.component.ts index 970cafed6ab..19c06627364 100644 --- a/src/app/modules/forms/search-input/components/basic-search/basic-search.component.ts +++ b/src/app/modules/forms/search-input/components/basic-search/basic-search.component.ts @@ -1,8 +1,6 @@ import { AfterViewInit, - ChangeDetectionStrategy, Component, ElementRef, Input, output, - viewChild, - input, + ChangeDetectionStrategy, Component, ElementRef, input, Input, output, viewChild, } from '@angular/core'; import { ReactiveFormsModule, FormsModule } from '@angular/forms'; import { MatInput } from '@angular/material/input'; diff --git a/src/app/modules/forms/search-input/components/search-input/search-input.component.ts b/src/app/modules/forms/search-input/components/search-input/search-input.component.ts index 97ce0ac60e8..5c2cc41694c 100644 --- a/src/app/modules/forms/search-input/components/search-input/search-input.component.ts +++ b/src/app/modules/forms/search-input/components/search-input/search-input.component.ts @@ -27,7 +27,7 @@ export class SearchInputComponent implements OnChanges { readonly allowAdvanced = input(true); readonly properties = input[]>([]); @Input() query: SearchQuery; - readonly advancedSearchPlaceholder = input(undefined); + readonly advancedSearchPlaceholder = input(); readonly queryChange = output>(); readonly runSearch = output(); diff --git a/src/app/modules/forms/search-input1/search-input1.component.html b/src/app/modules/forms/search-input1/search-input1.component.html index fd43ddc6979..4af04c9e4f6 100644 --- a/src/app/modules/forms/search-input1/search-input1.component.html +++ b/src/app/modules/forms/search-input1/search-input1.component.html @@ -8,7 +8,7 @@ ixTest="search" [maxLength]="maxLength()" [value]="searchValue" - [disabled]="disabled" + [disabled]="disabled()" [placeholder]="'Search' | translate" (input)="onInput(ixSearchInput.value)" /> diff --git a/src/app/modules/forms/search-input1/search-input1.component.ts b/src/app/modules/forms/search-input1/search-input1.component.ts index 9131e1364bf..a5d3de36527 100644 --- a/src/app/modules/forms/search-input1/search-input1.component.ts +++ b/src/app/modules/forms/search-input1/search-input1.component.ts @@ -2,11 +2,11 @@ import { ChangeDetectionStrategy, Component, ElementRef, - HostListener, Input, + HostListener, + viewChild, OnInit, OnChanges, HostBinding, output, - viewChild, input, } from '@angular/core'; import { MatInputModule } from '@angular/material/input'; @@ -35,8 +35,7 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; ], }) export class SearchInput1Component implements OnInit, OnChanges { - @HostBinding('class.disabled') - @Input() disabled = false; + readonly disabled = input(false); readonly value = input(''); readonly maxLength = input(524288); @@ -45,6 +44,11 @@ export class SearchInput1Component implements OnInit, OnChanges { readonly input = viewChild>('ixSearchInput'); + @HostBinding('class.disabled') + get disabledClass(): boolean { + return this.disabled(); + } + @HostListener('click') onHostClicked(): void { this.input().nativeElement.focus(); diff --git a/src/app/modules/ix-icon/ix-icon.component.ts b/src/app/modules/ix-icon/ix-icon.component.ts index f4ef52d849d..13b6a05f757 100644 --- a/src/app/modules/ix-icon/ix-icon.component.ts +++ b/src/app/modules/ix-icon/ix-icon.component.ts @@ -4,7 +4,7 @@ import { ChangeDetectionStrategy, Component, ElementRef, HostBinding, - Inject, Input, input, + Inject, input, OnChanges, OnInit, Optional, @@ -42,9 +42,12 @@ export class IxIconComponent extends MatIcon implements OnInit, OnChanges, After /** * Do not apply ordinary 24px size to the icon. */ - @Input() + readonly fullSize = input(false); + @HostBinding('class.full-size') - fullSize = false; + get fullSizeClass(): boolean { + return this.fullSize(); + } readonly name = input(); diff --git a/src/app/modules/ix-table/classes/api-data-provider/api-data-provider.ts b/src/app/modules/ix-table/classes/api-data-provider/api-data-provider.ts index f8dd807beec..10b0a8014ba 100644 --- a/src/app/modules/ix-table/classes/api-data-provider/api-data-provider.ts +++ b/src/app/modules/ix-table/classes/api-data-provider/api-data-provider.ts @@ -3,7 +3,6 @@ import { } from 'rxjs'; import { EmptyType } from 'app/enums/empty-type.enum'; import { ApiCallParams, ApiCallResponseType, QueryMethods } from 'app/interfaces/api/api-call-directory.interface'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { QueryFilters } from 'app/interfaces/query-api.interface'; import { PaginationServerSide } from 'app/modules/ix-table/classes/api-data-provider/pagination-server-side.class'; import { SortingServerSide } from 'app/modules/ix-table/classes/api-data-provider/sorting-server-side.class'; @@ -46,7 +45,7 @@ export class ApiDataProvider extends BaseDataProvider { + error: (error: unknown) => { console.error(this.method, error); this.totalRows = 0; this.rows = []; diff --git a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-state-button/ix-cell-state-button.component.ts b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-state-button/ix-cell-state-button.component.ts index 45b5ca4b346..daa328b365f 100644 --- a/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-state-button/ix-cell-state-button.component.ts +++ b/src/app/modules/ix-table/components/ix-table-body/cells/ix-cell-state-button/ix-cell-state-button.component.ts @@ -158,7 +158,7 @@ export class IxCellStateButtonComponent extends ColumnComponent implements canMinimize: true, }, ).afterClosed().pipe( - catchError((error) => { + catchError((error: unknown) => { this.errorHandler.showErrorModal(error); return EMPTY; }), diff --git a/src/app/modules/ix-table/components/ix-table-body/ix-table-body.component.spec.ts b/src/app/modules/ix-table/components/ix-table-body/ix-table-body.component.spec.ts index b4bb93a5c6e..613698cddf6 100644 --- a/src/app/modules/ix-table/components/ix-table-body/ix-table-body.component.spec.ts +++ b/src/app/modules/ix-table/components/ix-table-body/ix-table-body.component.spec.ts @@ -68,7 +68,7 @@ describe('IxTableBodyComponent', () => { spectator = createComponent({ props: { columns, dataProvider }, }); - spectator.component.dataProvider.setRows(testTableData); + spectator.component.dataProvider().setRows(testTableData); loader = TestbedHarnessEnvironment.loader(spectator.fixture); spectator.fixture.detectChanges(); }); diff --git a/src/app/modules/ix-table/components/ix-table-body/ix-table-body.component.ts b/src/app/modules/ix-table/components/ix-table-body/ix-table-body.component.ts index b5830af2350..5169fb9fc09 100644 --- a/src/app/modules/ix-table/components/ix-table-body/ix-table-body.component.ts +++ b/src/app/modules/ix-table/components/ix-table-body/ix-table-body.component.ts @@ -6,10 +6,9 @@ import { ChangeDetectorRef, ChangeDetectionStrategy, Component, - TemplateRef, output, contentChildren, contentChild, - input, + TemplateRef, output, input, } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; @@ -48,8 +47,8 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; ], }) export class IxTableBodyComponent implements AfterViewInit { - readonly columns = input>[]>(undefined); - readonly dataProvider = input>(undefined); + readonly columns = input>[]>(); + readonly dataProvider = input>(); readonly isLoading = input(false); readonly detailsRowIdentifier = input('id' as keyof T); @@ -95,15 +94,13 @@ export class IxTableBodyComponent implements AfterViewInit { } onToggle(row: T): void { - const dataProvider = this.dataProvider(); - dataProvider.expandedRow = this.isExpanded(row) ? null : row; - this.expanded.emit(dataProvider.expandedRow); + this.dataProvider().expandedRow = this.isExpanded(row) ? null : row; + this.expanded.emit(this.dataProvider().expandedRow); } isExpanded(row: T): boolean { - const detailsRowIdentifier = this.detailsRowIdentifier(); - return detailsRowIdentifier - && (this.dataProvider()?.expandedRow?.[detailsRowIdentifier] === row?.[detailsRowIdentifier]); + return this.detailsRowIdentifier() + && (this.dataProvider()?.expandedRow?.[this.detailsRowIdentifier()] === row?.[this.detailsRowIdentifier()]); } protected trackRowByIdentity(item: T): string { diff --git a/src/app/modules/ix-table/components/ix-table-pager/ix-table-pager.component.spec.ts b/src/app/modules/ix-table/components/ix-table-pager/ix-table-pager.component.spec.ts index cfc93108de2..174485426c4 100644 --- a/src/app/modules/ix-table/components/ix-table-pager/ix-table-pager.component.spec.ts +++ b/src/app/modules/ix-table/components/ix-table-pager/ix-table-pager.component.spec.ts @@ -51,25 +51,25 @@ describe('IxTablePagerComponent', () => { }); it('sets pagination when an option is selected', async () => { - const dataProvider = spectator.component.dataProvider; - expect(dataProvider().pagination).toEqual({ pageNumber: 1, pageSize: 2 }); + const dataProvider = spectator.component.dataProvider(); + expect(dataProvider.pagination).toEqual({ pageNumber: 1, pageSize: 2 }); const select = await loader.getHarness(MatSelectHarness); await select.open(); await select.clickOptions({ text: '10' }); - expect(dataProvider().pagination).toEqual({ pageNumber: 1, pageSize: 10 }); + expect(dataProvider.pagination).toEqual({ pageNumber: 1, pageSize: 10 }); expect(spectator.query('.pages').textContent.trim()).toBe('1 – 4 of 4'); }); it('sets pagination when page number is changed', async () => { - const dataProvider = spectator.component.dataProvider; - expect(dataProvider().pagination).toEqual({ pageNumber: 1, pageSize: 2 }); + const dataProvider = spectator.component.dataProvider(); + expect(dataProvider.pagination).toEqual({ pageNumber: 1, pageSize: 2 }); const buttons = await loader.getAllHarnesses(MatButtonHarness); await buttons[3].click(); - expect(dataProvider().pagination).toEqual({ pageNumber: 2, pageSize: 2 }); + expect(dataProvider.pagination).toEqual({ pageNumber: 2, pageSize: 2 }); expect(spectator.query('.pages').textContent.trim()).toBe('3 – 4 of 4'); }); diff --git a/src/app/modules/ix-table/components/ix-table-pager/ix-table-pager.component.ts b/src/app/modules/ix-table/components/ix-table-pager/ix-table-pager.component.ts index 190d013474a..ca12a54d7ab 100644 --- a/src/app/modules/ix-table/components/ix-table-pager/ix-table-pager.component.ts +++ b/src/app/modules/ix-table/components/ix-table-pager/ix-table-pager.component.ts @@ -1,6 +1,5 @@ import { - AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit, - input, + AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, input, Input, OnInit, } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatOption } from '@angular/material/core'; diff --git a/src/app/modules/ix-table/directives/ix-body-cell.directive.ts b/src/app/modules/ix-table/directives/ix-body-cell.directive.ts index d3bd16cfc25..92a2e10af58 100644 --- a/src/app/modules/ix-table/directives/ix-body-cell.directive.ts +++ b/src/app/modules/ix-table/directives/ix-body-cell.directive.ts @@ -14,8 +14,8 @@ import { Column, ColumnComponent, ColumnKeys } from 'app/modules/ix-table/interf standalone: true, }) export class IxTableBodyCellDirective implements AfterViewInit, OnChanges { - readonly row = input(undefined); - readonly column = input>>(undefined); + readonly row = input(); + readonly column = input>>(); private componentRef: ComponentRef>; @@ -36,13 +36,12 @@ export class IxTableBodyCellDirective implements AfterViewInit, OnChanges { } createComponent(): void { - const column = this.column(); - if (!column.type) { - column.type = IxCellTextComponent; + if (!this.column().type) { + this.column().type = IxCellTextComponent; } this.viewContainer.clear(); this.componentRef = this.viewContainer.createComponent( - column.type, + this.column().type, ); this.setComponentProps(); diff --git a/src/app/modules/ix-table/directives/ix-table-cell.directive.ts b/src/app/modules/ix-table/directives/ix-table-cell.directive.ts index 3835b42da36..9df90fb1be8 100644 --- a/src/app/modules/ix-table/directives/ix-table-cell.directive.ts +++ b/src/app/modules/ix-table/directives/ix-table-cell.directive.ts @@ -1,5 +1,5 @@ import { - Directive, Input, TemplateRef, input, + Directive, input, Input, TemplateRef, } from '@angular/core'; import { DataProvider } from 'app/modules/ix-table/interfaces/data-provider.interface'; @@ -8,7 +8,7 @@ import { DataProvider } from 'app/modules/ix-table/interfaces/data-provider.inte standalone: true, }) export class IxTableCellDirective { - readonly dataProvider = input | undefined>(undefined); + readonly dataProvider = input>(); @Input() columnIndex: number; constructor(public templateRef: TemplateRef<{ $implicit: T }>) {} diff --git a/src/app/modules/ix-tree/directives/tree-virtual-scroll-node-outlet.directive.ts b/src/app/modules/ix-tree/directives/tree-virtual-scroll-node-outlet.directive.ts index b872156b8dd..7062b3a66bd 100644 --- a/src/app/modules/ix-tree/directives/tree-virtual-scroll-node-outlet.directive.ts +++ b/src/app/modules/ix-tree/directives/tree-virtual-scroll-node-outlet.directive.ts @@ -1,7 +1,6 @@ import { CdkTreeNode, CdkTreeNodeOutletContext } from '@angular/cdk/tree'; import { - Directive, OnChanges, EmbeddedViewRef, ViewContainerRef, - input, + Directive, OnChanges, EmbeddedViewRef, ViewContainerRef, input, } from '@angular/core'; import { IxSimpleChange, IxSimpleChanges } from 'app/interfaces/simple-changes.interface'; import { TreeVirtualNodeData } from 'app/modules/ix-tree/interfaces/tree-virtual-node-data.interface'; @@ -18,7 +17,6 @@ export class TreeVirtualScrollNodeOutletDirective implements OnChanges { ngOnChanges(changes: IxSimpleChanges): void { const recreateView = this.shouldRecreateView(changes); - const dataValue = this.data(); if (recreateView) { const viewContainerRef = this._viewContainerRef; @@ -26,16 +24,15 @@ export class TreeVirtualScrollNodeOutletDirective implements OnChanges { viewContainerRef.remove(viewContainerRef.indexOf(this._viewRef)); } - const data = this.data(); - this._viewRef = data - ? viewContainerRef.createEmbeddedView(data.nodeDef.template, data.context) + this._viewRef = this.data() + ? viewContainerRef.createEmbeddedView(this.data().nodeDef.template, this.data().context) : null; if (CdkTreeNode.mostRecentTreeNode && this._viewRef) { CdkTreeNode.mostRecentTreeNode.data = this.data().data; } - } else if (this._viewRef && dataValue.context) { - this.updateExistingContext(dataValue.context); + } else if (this._viewRef && this.data().context) { + this.updateExistingContext(this.data().context); } } diff --git a/src/app/modules/jobs/store/job.effects.ts b/src/app/modules/jobs/store/job.effects.ts index fae77ceccc9..3649858c964 100644 --- a/src/app/modules/jobs/store/job.effects.ts +++ b/src/app/modules/jobs/store/job.effects.ts @@ -5,7 +5,7 @@ import { EMPTY, forkJoin, of } from 'rxjs'; import { catchError, filter, map, switchMap, } from 'rxjs/operators'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { JobState } from 'app/enums/job-state.enum'; import { abortJobPressed, jobAdded, jobChanged, jobRemoved, jobsLoaded, jobsNotLoaded, @@ -29,7 +29,7 @@ export class JobEffects { map(([notCompletedJobs, recentlyCompletedJobs]) => { return jobsLoaded({ jobs: [...notCompletedJobs, ...recentlyCompletedJobs] }); }), - catchError((error) => { + catchError((error: unknown) => { console.error(error); // TODO: See if it would make sense to parse middleware error. return of(jobsNotLoaded({ @@ -44,12 +44,12 @@ export class JobEffects { ofType(jobsLoaded), switchMap(() => { return this.api.subscribe('core.get_jobs').pipe( - filter((event) => event.msg !== IncomingApiMessageType.Removed), + filter((event) => event.msg !== CollectionChangeType.Removed), switchMap((event) => { switch (event.msg) { - case IncomingApiMessageType.Added: + case CollectionChangeType.Added: return of(jobAdded({ job: event.fields })); - case IncomingApiMessageType.Changed: + case CollectionChangeType.Changed: return of(jobChanged({ job: event.fields })); default: return EMPTY; @@ -63,7 +63,7 @@ export class JobEffects { ofType(jobsLoaded), switchMap(() => { return this.api.subscribe('core.get_jobs').pipe( - filter((event) => event.msg === IncomingApiMessageType.Removed), + filter((event) => event.msg === CollectionChangeType.Removed), map((event) => jobRemoved({ id: event.id as number })), ); }), diff --git a/src/app/modules/layout/copyright-line/copyright-line.component.html b/src/app/modules/layout/copyright-line/copyright-line.component.html index b2bea5623df..d8754c7ca96 100644 --- a/src/app/modules/layout/copyright-line/copyright-line.component.html +++ b/src/app/modules/layout/copyright-line/copyright-line.component.html @@ -3,10 +3,10 @@ - } @if (withIxLogo()) { diff --git a/src/app/modules/layout/copyright-line/copyright-line.component.spec.ts b/src/app/modules/layout/copyright-line/copyright-line.component.spec.ts index e9e8da180f5..c3217bf2202 100644 --- a/src/app/modules/layout/copyright-line/copyright-line.component.spec.ts +++ b/src/app/modules/layout/copyright-line/copyright-line.component.spec.ts @@ -37,6 +37,7 @@ describe('CopyrightLineComponent', () => { expect(spectator.fixture.nativeElement).toHaveText('TrueNAS ® © 2024'); expect(spectator.fixture.nativeElement).toHaveText('iXsystems, Inc'); + expect(spectator.query('a')).toHaveAttribute('href', 'https://truenas.com/testdrive'); }); it('shows copyright line with enterprise product type and year of build', () => { @@ -46,5 +47,6 @@ describe('CopyrightLineComponent', () => { expect(spectator.fixture.nativeElement).toHaveText('TrueNAS ENTERPRISE ® © 2024'); expect(spectator.fixture.nativeElement).toHaveText('iXsystems, Inc'); + expect(spectator.query('a')).toHaveAttribute('href', 'https://truenas.com/production'); }); }); diff --git a/src/app/modules/layout/copyright-line/copyright-line.component.ts b/src/app/modules/layout/copyright-line/copyright-line.component.ts index 42a4a17d165..edc6288a7da 100644 --- a/src/app/modules/layout/copyright-line/copyright-line.component.ts +++ b/src/app/modules/layout/copyright-line/copyright-line.component.ts @@ -1,11 +1,11 @@ import { - ChangeDetectionStrategy, Component, input, + ChangeDetectionStrategy, Component, computed, input, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; import { TestDirective } from 'app/modules/test-id/test.directive'; import { AppState } from 'app/store'; -import { selectCopyrightText } from 'app/store/system-info/system-info.selectors'; +import { selectCopyrightText, selectIsEnterprise } from 'app/store/system-info/system-info.selectors'; @Component({ selector: 'ix-copyright-line', @@ -18,6 +18,10 @@ import { selectCopyrightText } from 'app/store/system-info/system-info.selectors export class CopyrightLineComponent { readonly withIxLogo = input(false); readonly copyrightText = toSignal(this.store$.select(selectCopyrightText)); + readonly isEnterprise = toSignal(this.store$.select(selectIsEnterprise)); + readonly targetHref = computed(() => { + return this.isEnterprise() ? 'https://truenas.com/production' : 'https://truenas.com/testdrive'; + }); constructor(private store$: Store) { } } diff --git a/src/app/modules/loader/app-loader.service.ts b/src/app/modules/loader/app-loader.service.ts index 1195e119d96..66dc312718a 100644 --- a/src/app/modules/loader/app-loader.service.ts +++ b/src/app/modules/loader/app-loader.service.ts @@ -38,7 +38,7 @@ export class AppLoaderService { width: '200px', height: '200px', }); - this.dialogRef.componentInstance.title = title; + this.dialogRef.componentInstance.setTitle(title); return this.dialogRef.afterClosed(); } @@ -55,7 +55,7 @@ export class AppLoaderService { return; } - this.dialogRef.componentInstance.title = title; + this.dialogRef.componentInstance.setTitle(title); } private tryToRestoreFocusToThePreviousDialog(): void { diff --git a/src/app/modules/loader/components/app-loader/app-loader.component.html b/src/app/modules/loader/components/app-loader/app-loader.component.html index fdff731b76d..07696bd7d33 100644 --- a/src/app/modules/loader/components/app-loader/app-loader.component.html +++ b/src/app/modules/loader/components/app-loader/app-loader.component.html @@ -1,4 +1,4 @@ -
{{ title | translate }}
+
{{ title() | translate }}
diff --git a/src/app/modules/loader/components/app-loader/app-loader.component.ts b/src/app/modules/loader/components/app-loader/app-loader.component.ts index 03a4b893403..e073d0016ab 100644 --- a/src/app/modules/loader/components/app-loader/app-loader.component.ts +++ b/src/app/modules/loader/components/app-loader/app-loader.component.ts @@ -1,5 +1,5 @@ import { - Component, Input, ChangeDetectionStrategy, + Component, ChangeDetectionStrategy, signal, } from '@angular/core'; import { MatDialogContent } from '@angular/material/dialog'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; @@ -18,5 +18,9 @@ import { TranslateModule } from '@ngx-translate/core'; ], }) export class AppLoaderComponent { - @Input() title: string; + protected title = signal(''); + + setTitle(title: string): void { + this.title.set(title); + } } diff --git a/src/app/modules/loader/directives/with-loading-state/with-loading-state-error/with-loading-state-error.component.ts b/src/app/modules/loader/directives/with-loading-state/with-loading-state-error/with-loading-state-error.component.ts index 0934f1c979e..5e93242e3c0 100644 --- a/src/app/modules/loader/directives/with-loading-state/with-loading-state-error/with-loading-state-error.component.ts +++ b/src/app/modules/loader/directives/with-loading-state/with-loading-state-error/with-loading-state-error.component.ts @@ -1,5 +1,5 @@ import { - ChangeDetectionStrategy, Component, Input, + ChangeDetectionStrategy, Component, input, } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { isApiError } from 'app/helpers/api.helper'; @@ -13,10 +13,10 @@ import { ApiError } from 'app/interfaces/api-error.interface'; imports: [TranslateModule], }) export class WithLoadingStateErrorComponent { - @Input() error: Error | ApiError; + readonly error = input(); get errorMessage(): string { - const error = this.error; + const error = this.error(); if (isApiError(error)) { return error?.reason || error.error.toString(); } diff --git a/src/app/modules/loader/directives/with-loading-state/with-loading-state.directive.ts b/src/app/modules/loader/directives/with-loading-state/with-loading-state.directive.ts index 73ee3e83a1c..65520aa587f 100644 --- a/src/app/modules/loader/directives/with-loading-state/with-loading-state.directive.ts +++ b/src/app/modules/loader/directives/with-loading-state/with-loading-state.directive.ts @@ -55,7 +55,7 @@ export class WithLoadingStateDirective implements OnDestroy { break; case Boolean(state.error): { const errorComponent = this.viewContainerRef.createComponent(WithLoadingStateErrorComponent); - errorComponent.instance.error = state.error; + errorComponent.setInput('error', state.error); this.viewContainerRef.insert(errorComponent.hostView); break; } diff --git a/src/app/modules/master-detail-view/master-detail-view.component.html b/src/app/modules/master-detail-view/master-detail-view.component.html index 339ac71bde8..12e82093ecf 100644 --- a/src/app/modules/master-detail-view/master-detail-view.component.html +++ b/src/app/modules/master-detail-view/master-detail-view.component.html @@ -9,25 +9,15 @@ ixDetailsHeight [class.details-container-mobile]="showMobileDetails()" > -
-

-
- - {{ 'Details for' | translate }} -
- - - {{ 'Details for' | translate }} - - - - - +
+ @if(isMobileView()) { + + } +

+

- -
diff --git a/src/app/modules/master-detail-view/master-detail-view.component.scss b/src/app/modules/master-detail-view/master-detail-view.component.scss index 294ee4618b8..9987b20ec39 100644 --- a/src/app/modules/master-detail-view/master-detail-view.component.scss +++ b/src/app/modules/master-detail-view/master-detail-view.component.scss @@ -26,15 +26,14 @@ overflow: auto; } -.header { +.header-container { align-items: center; background: var(--bg1); - color: var(--fg1); + color: var(--fg2); display: flex; - justify-content: space-between; + gap: 8px; margin-bottom: 0; - margin-top: 20px; - padding-bottom: 16px; + padding: 15px 0; position: sticky; top: 0; z-index: 5; @@ -42,57 +41,11 @@ @media (max-width: calc($breakpoint-hidden - 1px)) { border-bottom: solid 1px var(--lines); margin: 0 16px 16px 0; + padding-top: 0; position: static; } -} - -.title { - align-items: center; - color: var(--fg2); - display: flex; - gap: 8px; - min-height: 36px; - width: 100%; - @media (max-width: $breakpoint-tablet) { - align-items: flex-start; - flex-direction: column; - gap: unset; - max-width: 100%; + .header { width: 100%; } - - @media (max-width: calc($breakpoint-hidden - 1px)) { - margin-top: 0; - } - - .mobile-prefix { - align-items: center; - display: none; - - @media (max-width: $breakpoint-hidden) { - display: flex; - max-width: 50%; - opacity: 0.85; - } - - @media (max-width: $breakpoint-tablet) { - max-width: 100%; - width: 100%; - } - } - - .prefix { - display: inline; - - @media (max-width: $breakpoint-hidden) { - display: none; - } - } - - .name { - @media (max-width: $breakpoint-tablet) { - margin-left: 40px; - } - } } diff --git a/src/app/modules/slide-ins/slide-in.component.ts b/src/app/modules/slide-ins/slide-in.component.ts index 0b2b9cc71c4..5ba103c09a8 100644 --- a/src/app/modules/slide-ins/slide-in.component.ts +++ b/src/app/modules/slide-ins/slide-in.component.ts @@ -30,7 +30,7 @@ import { SlideInService } from 'app/services/slide-in.service'; imports: [CdkTrapFocus], }) export class SlideInComponent implements OnInit, OnDestroy { - readonly id = input(undefined); + readonly id = input(); readonly slideInBody = viewChild('body', { read: ViewContainerRef }); @HostListener('document:keydown.escape') onKeydownHandler(): void { diff --git a/src/app/modules/test-id/test-override/test-override.directive.ts b/src/app/modules/test-id/test-override/test-override.directive.ts index 3b7dddae46a..c7842800428 100644 --- a/src/app/modules/test-id/test-override/test-override.directive.ts +++ b/src/app/modules/test-id/test-override/test-override.directive.ts @@ -13,5 +13,5 @@ import { }) export class TestOverrideDirective { readonly overrideDescription = input(undefined, { alias: 'ixTestOverride' }); - readonly keepLastPart = input(undefined); + readonly keepLastPart = input(); } diff --git a/src/app/pages/apps/components/app-detail-view/app-details-similar/app-details-similar.component.ts b/src/app/pages/apps/components/app-detail-view/app-details-similar/app-details-similar.component.ts index 00466efec47..51c08c6af29 100644 --- a/src/app/pages/apps/components/app-detail-view/app-details-similar/app-details-similar.component.ts +++ b/src/app/pages/apps/components/app-detail-view/app-details-similar/app-details-similar.component.ts @@ -51,7 +51,7 @@ export class AppDetailsSimilarComponent implements OnChanges { this.isLoading.set(false); this.similarApps.set(apps.slice(0, this.maxSimilarApps)); }, - error: (error) => { + error: (error: unknown) => { this.isLoading.set(false); console.error(error); this.loadingError.set(error); diff --git a/src/app/pages/apps/components/app-wizard/app-wizard.component.ts b/src/app/pages/apps/components/app-wizard/app-wizard.component.ts index a1db8221ae7..3989b251760 100644 --- a/src/app/pages/apps/components/app-wizard/app-wizard.component.ts +++ b/src/app/pages/apps/components/app-wizard/app-wizard.component.ts @@ -30,7 +30,6 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r import { DynamicFormSchemaType } from 'app/enums/dynamic-form-schema-type.enum'; import { Role } from 'app/enums/role.enum'; import { helptextApps } from 'app/helptext/apps/apps'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { AppDetailsRouteParams } from 'app/interfaces/app-details-route-params.interface'; import { ChartFormValue, @@ -202,7 +201,7 @@ export class AppWizardComponent implements OnInit, OnDestroy { }); this.afterAppLoaded(); }, - error: (error: ApiError) => this.afterAppLoadError(error), + error: (error: unknown) => this.afterAppLoadError(error), }); } @@ -334,7 +333,7 @@ export class AppWizardComponent implements OnInit, OnDestroy { this.setAppForEdit(releases[0]); this.afterAppLoaded(); }, - error: (error: ApiError) => this.afterAppLoadError(error), + error: (error: unknown) => this.afterAppLoadError(error), }); } diff --git a/src/app/pages/apps/components/custom-app-form/custom-app-form.component.ts b/src/app/pages/apps/components/custom-app-form/custom-app-form.component.ts index f9c617287ba..271954791d9 100644 --- a/src/app/pages/apps/components/custom-app-form/custom-app-form.component.ts +++ b/src/app/pages/apps/components/custom-app-form/custom-app-form.component.ts @@ -139,7 +139,7 @@ export class CustomAppFormComponent implements OnInit { this.router.navigate(['/apps', 'installed', this.data.metadata.train, this.data.name]); } }, - error: (error) => { + error: (error: unknown) => { this.isLoading.set(false); this.errorHandler.showErrorModal(error); }, diff --git a/src/app/pages/apps/components/docker-images/pull-image-form/pull-image-form.component.ts b/src/app/pages/apps/components/docker-images/pull-image-form/pull-image-form.component.ts index 660c81408bd..146a58e017d 100644 --- a/src/app/pages/apps/components/docker-images/pull-image-form/pull-image-form.component.ts +++ b/src/app/pages/apps/components/docker-images/pull-image-form/pull-image-form.component.ts @@ -99,7 +99,7 @@ export class PullImageFormComponent { this.cdr.markForCheck(); this.slideInRef.close(true); }, - error: (error) => { + error: (error: unknown) => { this.isFormLoading = false; this.cdr.markForCheck(); this.errorHandler.showErrorModal(error); diff --git a/src/app/pages/apps/components/installed-apps/app-bulk-upgrade/app-bulk-upgrade.component.ts b/src/app/pages/apps/components/installed-apps/app-bulk-upgrade/app-bulk-upgrade.component.ts index 723ead24dc4..e0402d073e3 100644 --- a/src/app/pages/apps/components/installed-apps/app-bulk-upgrade/app-bulk-upgrade.component.ts +++ b/src/app/pages/apps/components/installed-apps/app-bulk-upgrade/app-bulk-upgrade.component.ts @@ -131,7 +131,7 @@ export class AppBulkUpgradeComponent { }); this.loadingMap.set(name, false); }, - error: (error) => { + error: (error: unknown) => { console.error(error); this.loadingMap.set(name, false); }, diff --git a/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.html b/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.html index 620dfc5e960..5cc91322eb8 100644 --- a/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.html +++ b/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.html @@ -1,34 +1,19 @@ -
-

-
- - {{ 'Details' | translate }} -
- - {{ 'Details' | translate }} -

-
- -@if (app()) { -
-
- - - @if (app()?.notes) { - - } - @if (app()?.metadata) { - - } -
+
+
+ + + @if (app()?.notes) { + + } + @if (app()?.metadata) { + + }
-} +
diff --git a/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.scss b/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.scss deleted file mode 100644 index 35013da2ff2..00000000000 --- a/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.scss +++ /dev/null @@ -1,44 +0,0 @@ -@import 'mixins/cards'; -@import 'scss-imports/variables'; - -.header { - @media (max-width: calc($breakpoint-hidden - 1px)) { - border-bottom: solid 1px var(--lines); - margin: 0 16px 16px 0; - } -} - -.title { - margin-bottom: 12px; - margin-top: 20px; - min-height: 36px; - - @media (max-width: calc($breakpoint-hidden - 1px)) { - margin-top: 0; - } - - .mobile-prefix { - display: none; - - @media (max-width: $breakpoint-hidden) { - align-items: center; - display: flex; - } - } - - .prefix { - display: flex; - - @media (max-width: $breakpoint-hidden) { - display: none; - } - } -} - -.cards { - @include details-cards(); - - @media (max-width: $breakpoint-tablet) { - overflow: hidden; - } -} diff --git a/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.spec.ts b/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.spec.ts index d7556bde93d..75d13a1c5db 100644 --- a/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.spec.ts +++ b/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.spec.ts @@ -4,7 +4,6 @@ import { MockComponents } from 'ng-mocks'; import { ImgFallbackDirective } from 'ngx-img-fallback'; import { NgxPopperjsContentComponent, NgxPopperjsDirective, NgxPopperjsLooseDirective } from 'ngx-popperjs'; import { App } from 'app/interfaces/app.interface'; -import { MobileBackButtonComponent } from 'app/modules/buttons/mobile-back-button/mobile-back-button.component'; import { AppDetailsPanelComponent } from 'app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component'; import { AppInfoCardComponent } from 'app/pages/apps/components/installed-apps/app-info-card/app-info-card.component'; import { AppMetadataCardComponent } from 'app/pages/apps/components/installed-apps/app-metadata-card/app-metadata-card.component'; @@ -29,7 +28,6 @@ describe('AppDetailsPanelComponent', () => { AppInfoCardComponent, AppWorkloadsCardComponent, AppMetadataCardComponent, - MobileBackButtonComponent, ), ], providers: [], @@ -43,10 +41,6 @@ describe('AppDetailsPanelComponent', () => { }); }); - it('shows a title', () => { - expect(spectator.query('h2')).toHaveText('Details'); - }); - it('shows all the cards', () => { const appInfoCard = spectator.query(AppInfoCardComponent); expect(appInfoCard).toBeTruthy(); diff --git a/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.ts b/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.ts index db15d162a9b..19c26f1f027 100644 --- a/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.ts +++ b/src/app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component.ts @@ -3,7 +3,6 @@ import { } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { App } from 'app/interfaces/app.interface'; -import { MobileBackButtonComponent } from 'app/modules/buttons/mobile-back-button/mobile-back-button.component'; import { AppInfoCardComponent } from 'app/pages/apps/components/installed-apps/app-info-card/app-info-card.component'; import { AppMetadataCardComponent } from 'app/pages/apps/components/installed-apps/app-metadata-card/app-metadata-card.component'; import { AppNotesCardComponent } from 'app/pages/apps/components/installed-apps/app-notes-card/app-notes-card.component'; @@ -12,7 +11,6 @@ import { AppWorkloadsCardComponent } from 'app/pages/apps/components/installed-a @Component({ selector: 'ix-app-details-panel', templateUrl: './app-details-panel.component.html', - styleUrls: ['./app-details-panel.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ @@ -20,7 +18,6 @@ import { AppWorkloadsCardComponent } from 'app/pages/apps/components/installed-a AppInfoCardComponent, AppWorkloadsCardComponent, AppNotesCardComponent, - MobileBackButtonComponent, AppMetadataCardComponent, ], }) @@ -30,8 +27,4 @@ export class AppDetailsPanelComponent { readonly startApp = output(); readonly stopApp = output(); readonly closeMobileDetails = output(); - - onCloseMobileDetails(): void { - this.closeMobileDetails.emit(); - } } diff --git a/src/app/pages/apps/components/installed-apps/container-logs/container-logs.component.ts b/src/app/pages/apps/components/installed-apps/container-logs/container-logs.component.ts index b35a85d3a23..63b5e5bd763 100644 --- a/src/app/pages/apps/components/installed-apps/container-logs/container-logs.component.ts +++ b/src/app/pages/apps/components/installed-apps/container-logs/container-logs.component.ts @@ -12,7 +12,6 @@ import { TranslateModule } from '@ngx-translate/core'; import { combineLatest, map, Subscription, switchMap, tap, } from 'rxjs'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { ToolbarSliderComponent } from 'app/modules/forms/toolbar-slider/toolbar-slider.component'; import { AppLoaderService } from 'app/modules/loader/app-loader.service'; @@ -120,11 +119,9 @@ export class ContainerLogsComponent implements OnInit { this.cdr.markForCheck(); }, - error: (error: ApiError) => { + error: (error: unknown) => { this.isLoading = false; - if (error.reason) { - this.dialogService.error(this.errorHandler.parseError(error)); - } + this.dialogService.error(this.errorHandler.parseError(error)); this.cdr.markForCheck(); }, }); diff --git a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.html b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.html new file mode 100644 index 00000000000..1b046652f54 --- /dev/null +++ b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.html @@ -0,0 +1,57 @@ +
+
+ {{ checkedApps().length }} + {{ 'Selected' | translate }} +
+ +
+ + +
+ + + + + + + +
diff --git a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.scss b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.scss new file mode 100644 index 00000000000..6fc743d3b4d --- /dev/null +++ b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.scss @@ -0,0 +1,31 @@ +.bulk-selected { + align-items: center; + align-self: flex-end; + display: inline-flex; + font-size: 16px; + gap: 4px; + height: 36px; +} + +.bulk-actions-container { + align-items: flex-end; + display: flex; + gap: 12px; +} + +.bulk-button-wrapper { + display: flex; + flex-direction: column; + + label { + color: var(--fg2); + font-size: 10px; + margin-bottom: 2px; + } + + button { + background-color: var(--bg1); + border: 1px solid var(--lines); + font-size: 12px; + } +} diff --git a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.spec.ts b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.spec.ts new file mode 100644 index 00000000000..e7c45befdfd --- /dev/null +++ b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.spec.ts @@ -0,0 +1,80 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatMenuHarness } from '@angular/material/menu/testing'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; +import { AppState } from 'app/enums/app-state.enum'; +import { App } from 'app/interfaces/app.interface'; +import { InstalledAppsListBulkActionsComponent } from './installed-apps-list-bulk-actions.component'; + +describe('InstalledAppsListBulkActionsComponent', () => { + let spectator: Spectator; + let loader: HarnessLoader; + let menu: MatMenuHarness; + + const checkedAppsMock = [ + { id: 'ix-app-1', state: AppState.Running, upgrade_available: true }, + { id: 'ix-app-2', state: AppState.Stopped }, + ] as App[]; + + const createComponent = createComponentFactory({ + component: InstalledAppsListBulkActionsComponent, + imports: [MatMenuModule], + providers: [ + mockAuth(), + ], + }); + + beforeEach(async () => { + spectator = createComponent({ + props: { + checkedApps: checkedAppsMock, + }, + }); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + menu = await loader.getHarness(MatMenuHarness); + await menu.open(); + }); + + it('displays the correct count of selected instances', () => { + const selectedCount = spectator.query('.bulk-selected span:first-child'); + expect(selectedCount).toHaveText(String(checkedAppsMock.length)); + }); + + it('emits bulkStart after actions', async () => { + const startSpy = jest.spyOn(spectator.component.bulkStart, 'emit'); + + await menu.open(); + await menu.clickItem({ text: 'Start All Selected' }); + + expect(startSpy).toHaveBeenCalled(); + }); + + it('emits bulkStop after actions', async () => { + const stopSpy = jest.spyOn(spectator.component.bulkStop, 'emit'); + + await menu.open(); + await menu.clickItem({ text: 'Stop All Selected' }); + + expect(stopSpy).toHaveBeenCalled(); + }); + + it('emits bulkUpgrade after actions', async () => { + const upgradeSpy = jest.spyOn(spectator.component.bulkUpgrade, 'emit'); + + await menu.open(); + await menu.clickItem({ text: 'Upgrade All Selected' }); + + expect(upgradeSpy).toHaveBeenCalled(); + }); + + it('emits bulkDelete after actions', async () => { + const deleteSpy = jest.spyOn(spectator.component.bulkDelete, 'emit'); + + await menu.open(); + await menu.clickItem({ text: 'Delete All Selected' }); + + expect(deleteSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.ts b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.ts new file mode 100644 index 00000000000..dbe50de6204 --- /dev/null +++ b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component.ts @@ -0,0 +1,59 @@ +import { + Component, + ChangeDetectionStrategy, + input, + output, +} from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { TranslateModule } from '@ngx-translate/core'; +import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; +import { AppState } from 'app/enums/app-state.enum'; +import { Role } from 'app/enums/role.enum'; +import { App } from 'app/interfaces/app.interface'; +import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; + +@UntilDestroy() +@Component({ + selector: 'ix-installed-apps-list-bulk-actions', + templateUrl: './installed-apps-list-bulk-actions.component.html', + styleUrls: ['./installed-apps-list-bulk-actions.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + MatMenuTrigger, + MatMenu, + MatMenuItem, + RequiresRolesDirective, + IxIconComponent, + MatButton, + TranslateModule, + ], +}) + +export class InstalledAppsListBulkActionsComponent { + readonly checkedApps = input.required(); + readonly bulkStart = output(); + readonly bulkStop = output(); + readonly bulkUpgrade = output(); + readonly bulkDelete = output(); + + protected readonly requiredRoles = [Role.AppsWrite]; + + get isBulkStartDisabled(): boolean { + return this.checkedApps().every( + (app) => [AppState.Running, AppState.Deploying].includes(app.state), + ); + } + + get isBulkStopDisabled(): boolean { + return this.checkedApps().every( + (app) => [AppState.Stopped, AppState.Crashed].includes(app.state), + ); + } + + get isBulkUpgradeDisabled(): boolean { + return !this.checkedApps().some((app) => app.upgrade_available); + } +} diff --git a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.html b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.html new file mode 100644 index 00000000000..6b52a4d9b00 --- /dev/null +++ b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.html @@ -0,0 +1,123 @@ + +
+

{{ 'Applications' | translate }}

+ + @if (hasCheckedApps) { + + } +
+ + + + + +
+
+
+ @for (app of filteredApps; track app.name) { + + } + + @if ((dataSource.length && !filteredApps.length) || (!dataSource.length && !isLoading())) { +
+ +
+ } +
+
+
diff --git a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.scss b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.scss new file mode 100644 index 00000000000..6af3d3dbea0 --- /dev/null +++ b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.scss @@ -0,0 +1,117 @@ +@import 'scss-imports/variables'; + +.table-header { + align-items: flex-end; + color: var(--fg1); + display: flex; + justify-content: space-between; + margin-bottom: 12px; + min-height: 56px; + + h2 { + align-items: center; + display: flex; + margin-top: 20px; + min-height: 36px; + } + + .bulk { + align-items: center; + display: flex; + gap: 6px; + } +} + +.app-wrapper { + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; +} + +.app-inner { + background-color: var(--bg2); + display: flex; + flex: 1; + flex-direction: column; + min-width: fit-content; + + @media (max-width: $breakpoint-tablet) { + min-height: fit-content; + min-width: unset; + overflow-x: auto; + } +} + +.no-apps { + color: var(--fg1); + display: flex; + justify-content: center; + margin: 15px; +} + +ix-app-row, +.app-header-row { + grid-template-columns: 5% minmax(18%, auto) 11% 4% 7% 11% 11% 13% 10%; + + @media (max-width: $breakpoint-tablet) { + grid-template-columns: 45px auto 0 0 0 0 0 0; + } +} + +.app-header-row { + align-items: center; + background: var(--bg1); + border-bottom: 1px solid var(--lines); + color: var(--fg2); + display: grid; + grid-gap: 8px; + min-height: 48px; + min-width: fit-content; + + position: sticky; + top: 0; + width: 100%; + z-index: 1; + + > div { + align-items: center; + display: flex; + font-weight: bold; + height: 100%; + justify-content: flex-start; + padding: 4px 0; + + @media (max-width: $breakpoint-tablet) { + display: none !important; + } + + &:first-child { + left: 0; + position: sticky; + + @media (max-width: $breakpoint-tablet) { + display: block !important; + } + } + + &:nth-child(2) { + @media (max-width: $breakpoint-tablet) { + display: flex !important; + } + } + } + + .app-update-header { + align-items: center; + display: flex; + gap: 4px; + + ix-icon { + color: var(--yellow); + font-size: 18px; + line-height: 1; + margin-left: 4px; + } + } +} diff --git a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.spec.ts b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.spec.ts new file mode 100644 index 00000000000..4cebc31296f --- /dev/null +++ b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.spec.ts @@ -0,0 +1,227 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, Router } from '@angular/router'; +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { MockDeclaration } from 'ng-mocks'; +import { of } from 'rxjs'; +import { mockApi, mockJob } from 'app/core/testing/utils/mock-api.utils'; +import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; +import { AppState } from 'app/enums/app-state.enum'; +import { JobState } from 'app/enums/job-state.enum'; +import { App } from 'app/interfaces/app.interface'; +import { DialogService } from 'app/modules/dialog/dialog.service'; +import { EmptyComponent } from 'app/modules/empty/empty.component'; +import { SearchInput1Component } from 'app/modules/forms/search-input1/search-input1.component'; +import { FakeProgressBarComponent } from 'app/modules/loader/components/fake-progress-bar/fake-progress-bar.component'; +import { AppDeleteDialogComponent } from 'app/pages/apps/components/app-delete-dialog/app-delete-dialog.component'; +import { AppBulkUpgradeComponent } from 'app/pages/apps/components/installed-apps/app-bulk-upgrade/app-bulk-upgrade.component'; +import { AppDetailsPanelComponent } from 'app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component'; +import { AppRowComponent } from 'app/pages/apps/components/installed-apps/app-row/app-row.component'; +import { InstalledAppsListBulkActionsComponent } from 'app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component'; +import { InstalledAppsListComponent } from 'app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component'; +import { ApplicationsService } from 'app/pages/apps/services/applications.service'; +import { AppsStatsService } from 'app/pages/apps/store/apps-stats.service'; +import { AppsStore } from 'app/pages/apps/store/apps-store.service'; +import { DockerStore } from 'app/pages/apps/store/docker.store'; +import { InstalledAppsStore } from 'app/pages/apps/store/installed-apps-store.service'; +import { ApiService } from 'app/services/websocket/api.service'; + +const apps = [ + { + id: 'ix-test-app-1', + name: 'test-app-1', + metadata: { + name: 'rude-cardinal', + train: 'test-catalog-train', + }, + state: AppState.Running, + upgrade_available: true, + }, + { + + id: 'ix-test-app-2', + name: 'test-app-2', + metadata: { + name: 'rude-cardinal', + train: 'test-catalog-train', + }, + state: AppState.Stopped, + upgrade_available: true, + }, +] as App[]; + +describe('InstalledAppsListComponent', () => { + let spectator: Spectator; + let applicationsService: ApplicationsService; + let loader: HarnessLoader; + + const createComponent = createComponentFactory({ + component: InstalledAppsListComponent, + imports: [ + MatTableModule, + FakeProgressBarComponent, + ], + declarations: [ + EmptyComponent, + SearchInput1Component, + MockDeclaration(AppDetailsPanelComponent), + ], + providers: [ + mockProvider(DockerStore, { + isDockerStarted$: of(true), + selectedPool$: of('pool'), + }), + mockProvider(InstalledAppsStore, { + isLoading$: of(false), + installedApps$: of(apps), + }), + mockProvider(AppsStore, { + isLoading$: of(false), + availableApps$: of([]), + }), + mockProvider(DialogService, { + jobDialog: jest.fn(() => ({ + afterClosed: () => of({ result: [{ error: 'test error' }] }), + })), + }), + mockProvider(MatDialog, { + open: jest.fn(() => ({ + afterClosed: () => of(null), + })), + }), + mockProvider(Router, { + events: of(), + }), + mockProvider(ApplicationsService, { + restartApplication: jest.fn(() => of(null)), + startApplication: jest.fn(() => of(null)), + stopApplication: jest.fn(() => of(null)), + getInstalledAppsStatusUpdates: jest.fn(() => of({ + fields: { arguments: ['test-app', { replica_count: 1 }], state: JobState.Success }, + })), + checkIfAppIxVolumeExists: jest.fn(() => of(true)), + }), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: { + get: () => 'unknown_id', + }, + }, + }, + }, + mockApi([ + mockJob('core.bulk'), + ]), + mockAuth(), + mockProvider(AppsStatsService), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + applicationsService = spectator.inject(ApplicationsService); + }); + + it('shows a list of apps', () => { + const appRows = spectator.queryAll(AppRowComponent); + + expect(appRows).toHaveLength(2); + expect(appRows[0].app()).toEqual(apps[0]); + expect(appRows[1].app()).toEqual(apps[1]); + }); + + it('shows an empty list when there are no search results', () => { + expect(spectator.query(EmptyComponent)).not.toExist(); + + spectator.query(SearchInput1Component).search.emit('test-app-3'); + spectator.detectChanges(); + + const appRows = spectator.queryAll(AppRowComponent); + expect(appRows).toHaveLength(0); + + expect(spectator.query(EmptyComponent)).toExist(); + }); + + it('shows details', () => { + spectator.click(spectator.query('ix-app-row')); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith([ + '/apps/installed', 'test-catalog-train', 'ix-test-app-1', + ]); + }); + + it('starts application', () => { + spectator.query(AppRowComponent).startApp.emit(); + expect(applicationsService.startApplication).toHaveBeenCalledWith('test-app-1'); + }); + + it('stops application', () => { + spectator.query(AppRowComponent).stopApp.emit(); + expect(applicationsService.stopApplication).toHaveBeenCalledWith('test-app-1'); + }); + + it('restarts application', () => { + spectator.query(AppRowComponent).restartApp.emit(); + expect(applicationsService.restartApplication).toHaveBeenCalledWith('test-app-1'); + }); + + it('starts sereral applications', async () => { + const selectAll = await loader.getHarness(MatCheckboxHarness.with({ selector: '[ixTest="select-all-app"]' })); + await selectAll.check(); + spectator.query(InstalledAppsListBulkActionsComponent).bulkStart.emit(); + + expect(applicationsService.startApplication).toHaveBeenCalledWith('test-app-2'); + }); + + it('stops sereral applications', async () => { + const selectAll = await loader.getHarness(MatCheckboxHarness.with({ selector: '[ixTest="select-all-app"]' })); + await selectAll.check(); + spectator.query(InstalledAppsListBulkActionsComponent).bulkStop.emit(); + + expect(applicationsService.stopApplication).toHaveBeenCalledWith('test-app-1'); + }); + + it('upgrades sereral applications', async () => { + const selectAll = await loader.getHarness(MatCheckboxHarness.with({ selector: '[ixTest="select-all-app"]' })); + await selectAll.check(); + spectator.query(InstalledAppsListBulkActionsComponent).bulkUpgrade.emit(); + + expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith(AppBulkUpgradeComponent, { data: apps }); + }); + + it('removes sereral applications', async () => { + jest.spyOn(spectator.inject(MatDialog), 'open').mockReturnValue({ + afterClosed: () => of({ removeVolumes: true, removeImages: true }), + } as MatDialogRef); + + const selectAll = await loader.getHarness(MatCheckboxHarness.with({ selector: '[ixTest="select-all-app"]' })); + await selectAll.check(); + spectator.query(InstalledAppsListBulkActionsComponent).bulkDelete.emit(); + + expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith(AppDeleteDialogComponent, { + data: { + name: 'ix-test-app-1, ix-test-app-2', + showRemoveVolumes: true, + }, + }); + + expect(spectator.inject(ApiService).job).toHaveBeenCalledWith('core.bulk', [ + 'app.delete', + [ + [ + 'ix-test-app-1', + { remove_images: true, remove_ix_volumes: true }, + ], + [ + 'ix-test-app-2', + { remove_images: true, remove_ix_volumes: true }, + ], + ], + ]); + }); +}); diff --git a/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts new file mode 100644 index 00000000000..c1c35012b3e --- /dev/null +++ b/src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts @@ -0,0 +1,513 @@ +import { SelectionModel } from '@angular/cdk/collections'; +import { AsyncPipe, Location } from '@angular/common'; +import { + Component, ChangeDetectionStrategy, + output, + input, OnInit, + ChangeDetectorRef, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSort, MatSortHeader, Sort } from '@angular/material/sort'; +import { MatColumnDef } from '@angular/material/table'; +import { MatTooltip } from '@angular/material/tooltip'; +import { + ActivatedRoute, NavigationEnd, NavigationStart, Router, +} from '@angular/router'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { Store } from '@ngrx/store'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + combineLatest, filter, forkJoin, Observable, switchMap, +} from 'rxjs'; +import { AppState } from 'app/enums/app-state.enum'; +import { EmptyType } from 'app/enums/empty-type.enum'; +import { helptextApps } from 'app/helptext/apps/apps'; +import { App, AppStartQueryParams, AppStats } from 'app/interfaces/app.interface'; +import { CoreBulkResponse } from 'app/interfaces/core-bulk.interface'; +import { EmptyConfig } from 'app/interfaces/empty-config.interface'; +import { Job } from 'app/interfaces/job.interface'; +import { DialogService } from 'app/modules/dialog/dialog.service'; +import { EmptyComponent } from 'app/modules/empty/empty.component'; +import { SearchInput1Component } from 'app/modules/forms/search-input1/search-input1.component'; +import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; +import { SortDirection } from 'app/modules/ix-table/enums/sort-direction.enum'; +import { selectJob } from 'app/modules/jobs/store/job.selectors'; +import { AppLoaderService } from 'app/modules/loader/app-loader.service'; +import { FakeProgressBarComponent } from 'app/modules/loader/components/fake-progress-bar/fake-progress-bar.component'; +import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; +import { TestDirective } from 'app/modules/test-id/test.directive'; +import { AppDeleteDialogComponent } from 'app/pages/apps/components/app-delete-dialog/app-delete-dialog.component'; +import { AppDeleteDialogInputData, AppDeleteDialogOutputData } from 'app/pages/apps/components/app-delete-dialog/app-delete-dialog.interface'; +import { AppBulkUpgradeComponent } from 'app/pages/apps/components/installed-apps/app-bulk-upgrade/app-bulk-upgrade.component'; +import { AppRowComponent } from 'app/pages/apps/components/installed-apps/app-row/app-row.component'; +import { InstalledAppsListBulkActionsComponent } from 'app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list-bulk-actions/installed-apps-list-bulk-actions.component'; +import { installedAppsElements } from 'app/pages/apps/components/installed-apps/installed-apps.elements'; +import { ApplicationsService } from 'app/pages/apps/services/applications.service'; +import { AppsStatsService } from 'app/pages/apps/store/apps-stats.service'; +import { DockerStore } from 'app/pages/apps/store/docker.store'; +import { InstalledAppsStore } from 'app/pages/apps/store/installed-apps-store.service'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; +import { ApiService } from 'app/services/websocket/api.service'; +import { AppState as WebuiAppState } from 'app/store'; + +enum SortableField { + Application = 'application', + State = 'state', + Updates = 'updates', +} + +function doSortCompare(a: number | string, b: number | string, isAsc: boolean): number { + return (a < b ? -1 : 1) * (isAsc ? 1 : -1); +} + +@UntilDestroy() +@Component({ + selector: 'ix-installed-apps-list', + templateUrl: './installed-apps-list.component.html', + styleUrls: ['./installed-apps-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + InstalledAppsListBulkActionsComponent, + FakeProgressBarComponent, + SearchInput1Component, + IxIconComponent, + MatSort, + AsyncPipe, + MatCheckbox, + MatColumnDef, + MatSortHeader, + AppRowComponent, + EmptyComponent, + MatTooltip, + TestDirective, + TranslateModule, + ], +}) + +export class InstalledAppsListComponent implements OnInit { + readonly isMobileView = input(); + readonly toggleShowMobileDetails = output(); + + protected readonly searchableElements = installedAppsElements; + readonly isLoading = toSignal(this.installedAppsStore.isLoading$, { requireSync: true }); + + dataSource: App[] = []; + selectedApp: App; + filterString = ''; + appJobs = new Map>(); + selection = new SelectionModel(true, []); + sortingInfo: Sort = { + active: SortableField.Application, + direction: SortDirection.Asc, + }; + + readonly sortableField = SortableField; + + entityEmptyConf: EmptyConfig = { + type: EmptyType.Loading, + large: false, + title: helptextApps.message.loading, + }; + + get filteredApps(): App[] { + return this.dataSource + .filter((app) => app?.name?.toLocaleLowerCase().includes(this.filterString.toLocaleLowerCase())); + } + + get allAppsChecked(): boolean { + return this.selection.selected.length === this.filteredApps.length; + } + + get hasCheckedApps(): boolean { + return this.checkedAppsNames.length > 0; + } + + get appsUpdateAvailable(): number { + return this.dataSource + .filter((app) => app.upgrade_available).length; + } + + get hasUpdates(): boolean { + return this.dataSource.some((app) => app.upgrade_available); + } + + get checkedAppsNames(): string[] { + return this.selection.selected; + } + + get checkedApps(): App[] { + return this.checkedAppsNames.map((id) => this.dataSource.find((app) => app.id === id)); + } + + get activeCheckedApps(): App[] { + return this.dataSource.filter( + (app) => [AppState.Running, AppState.Deploying].includes(app.state) && this.selection.isSelected(app.id), + ); + } + + get stoppedCheckedApps(): App[] { + return this.dataSource.filter( + (app) => [AppState.Stopped, AppState.Crashed].includes(app.state) && this.selection.isSelected(app.id), + ); + } + + constructor( + private api: ApiService, + private appService: ApplicationsService, + private cdr: ChangeDetectorRef, + private activatedRoute: ActivatedRoute, + private router: Router, + private matDialog: MatDialog, + private dialogService: DialogService, + private snackbar: SnackbarService, + private translate: TranslateService, + private installedAppsStore: InstalledAppsStore, + private dockerStore: DockerStore, + private errorHandler: ErrorHandlerService, + private store$: Store, + private location: Location, + private appsStats: AppsStatsService, + private loader: AppLoaderService, + ) { + this.router.events + .pipe( + filter((event) => event instanceof NavigationStart || event instanceof NavigationEnd), + untilDestroyed(this), + ) + .subscribe(() => { + if (this.router.getCurrentNavigation()?.extras?.state?.hideMobileDetails) { + this.closeMobileDetails(); + this.selectedApp = undefined; + this.cdr.markForCheck(); + } + }); + } + + ngOnInit(): void { + this.loadInstalledApps(); + this.listenForStatusUpdates(); + } + + closeMobileDetails(): void { + this.toggleShowMobileDetails.emit(false); + } + + viewDetails(app: App): void { + this.selectAppForDetails(app.id); + + this.router.navigate(['/apps/installed', app.metadata.train, app.id]); + + if (this.isMobileView()) { + this.toggleShowMobileDetails.emit(true); + } + } + + onSearch(query: string): void { + this.filterString = query; + + if (!this.filteredApps.length) { + this.showLoadStatus(EmptyType.NoSearchResults); + } + } + + toggleAppsChecked(checked: boolean): void { + if (checked) { + this.dataSource.forEach((app) => this.selection.select(app.id)); + } else { + this.selection.clear(); + } + } + + showLoadStatus(type: EmptyType.FirstUse | EmptyType.NoPageData | EmptyType.Errors | EmptyType.NoSearchResults): void { + switch (type) { + case EmptyType.FirstUse: + case EmptyType.NoPageData: + this.entityEmptyConf.title = helptextApps.message.no_installed; + this.entityEmptyConf.message = this.translate.instant('Applications you install will automatically appear here. Click below and browse available apps to get started.'); + this.entityEmptyConf.button = { + label: this.translate.instant('Check Available Apps'), + action: () => this.redirectToAvailableApps(), + }; + break; + case EmptyType.Errors: + this.entityEmptyConf.title = helptextApps.message.not_running; + this.entityEmptyConf.message = undefined; + break; + case EmptyType.NoSearchResults: + this.entityEmptyConf.title = helptextApps.message.no_search_result; + this.entityEmptyConf.message = undefined; + this.entityEmptyConf.button = { + label: this.translate.instant('Reset Search'), + action: () => { + this.resetSearch(); + this.cdr.markForCheck(); + }, + }; + break; + } + + this.entityEmptyConf.type = type; + } + + loadInstalledApps(): void { + this.cdr.markForCheck(); + + combineLatest([ + this.dockerStore.selectedPool$, + this.dockerStore.isDockerStarted$, + this.installedAppsStore.installedApps$, + ]).pipe( + filter(([pool]) => { + if (!pool) { + this.dataSource = []; + this.showLoadStatus(EmptyType.FirstUse); + this.cdr.markForCheck(); + this.redirectToInstalledAppsWithoutDetails(); + } + return !!pool; + }), + filter(([,dockerStarted]) => { + if (!dockerStarted) { + this.dataSource = []; + this.showLoadStatus(EmptyType.Errors); + this.cdr.markForCheck(); + this.redirectToInstalledAppsWithoutDetails(); + } + return !!dockerStarted; + }), + filter(([,, apps]) => { + if (!apps.length) { + this.dataSource = []; + this.showLoadStatus(EmptyType.NoPageData); + this.cdr.markForCheck(); + this.redirectToInstalledAppsWithoutDetails(); + } + return !!apps.length; + }), + untilDestroyed(this), + ).subscribe({ + next: ([,, apps]) => { + this.sortChanged(this.sortingInfo, apps); + this.selectAppForDetails(this.activatedRoute.snapshot.paramMap.get('appId')); + this.cdr.markForCheck(); + }, + }); + } + + start(name: string): void { + this.appService.startApplication(name) + .pipe( + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe((job: Job) => { + this.appJobs.set(name, job); + this.sortChanged(this.sortingInfo); + this.cdr.markForCheck(); + }); + } + + stop(name: string): void { + this.appService.stopApplication(name) + .pipe( + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe({ + next: (job: Job) => { + this.appJobs.set(name, job); + this.sortChanged(this.sortingInfo); + this.cdr.markForCheck(); + }, + }); + } + + restart(name: string): void { + this.appService.restartApplication(name) + .pipe( + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe((job: Job) => { + this.appJobs.set(name, job); + this.sortChanged(this.sortingInfo); + this.cdr.markForCheck(); + }); + } + + openStatusDialog(name: string): void { + if (!this.appJobs.has(name)) { + return; + } + const job$ = this.store$.select(selectJob(this.appJobs.get(name).id)); + this.dialogService.jobDialog(job$, { title: name, canMinimize: true }) + .afterClosed() + .pipe(this.errorHandler.catchError(), untilDestroyed(this)) + .subscribe(); + } + + onBulkStart(): void { + this.stoppedCheckedApps.forEach((app) => this.start(app.name)); + this.snackbar.success(this.translate.instant(helptextApps.bulkActions.finished)); + this.toggleAppsChecked(false); + } + + onBulkStop(): void { + this.activeCheckedApps.forEach((app) => this.stop(app.name)); + this.snackbar.success(this.translate.instant(helptextApps.bulkActions.finished)); + this.toggleAppsChecked(false); + } + + onBulkUpgrade(updateAll = false): void { + const apps = this.dataSource.filter((app) => ( + updateAll ? app.upgrade_available : this.selection.isSelected(app.id) + )); + this.matDialog.open(AppBulkUpgradeComponent, { data: apps }) + .afterClosed() + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.toggleAppsChecked(false); + }); + } + + onBulkDelete(): void { + forkJoin(this.checkedAppsNames.map((appName) => this.appService.checkIfAppIxVolumeExists(appName))) + .pipe( + this.loader.withLoader(), + switchMap((ixVolumesExist) => { + return this.matDialog.open< + AppDeleteDialogComponent, + AppDeleteDialogInputData, + AppDeleteDialogOutputData + >(AppDeleteDialogComponent, { + data: { + name: this.checkedAppsNames.join(', '), + showRemoveVolumes: ixVolumesExist.some(Boolean), + }, + }).afterClosed(); + }), + filter(Boolean), + switchMap(({ removeVolumes, removeImages }) => this.executeBulkDeletion(removeVolumes, removeImages)), + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe((job: Job) => this.handleDeletionResult(job)); + } + + sortChanged(sort: Sort, apps?: App[]): void { + this.sortingInfo = sort; + + this.dataSource = (apps || this.dataSource).sort((a, b) => { + const isAsc = sort.direction === SortDirection.Asc; + + switch (sort.active as SortableField) { + case SortableField.Application: + return doSortCompare(a.name, b.name, isAsc); + case SortableField.State: + return doSortCompare(a.state, b.state, isAsc); + case SortableField.Updates: + return doSortCompare( + a.upgrade_available ? 1 : 0, + b.upgrade_available ? 1 : 0, + isAsc, + ); + default: + return doSortCompare(a.name, b.name, isAsc); + } + }); + } + + private executeBulkDeletion( + removeVolumes = false, + removeImages = true, + ): Observable> { + const bulkDeletePayload = this.checkedAppsNames.map((name) => [ + name, + { remove_images: removeImages, remove_ix_volumes: removeVolumes }, + ]); + + return this.dialogService.jobDialog( + this.api.job('core.bulk', ['app.delete', bulkDeletePayload]), + { title: helptextApps.apps.delete_dialog.job }, + ).afterClosed(); + } + + private handleDeletionResult(job: Job): void { + if (!this.dataSource.length) { + this.redirectToInstalledAppsWithoutDetails(); + } + + this.dialogService.closeAllDialogs(); + const errorMessages = this.getErrorMessages(job.result); + + if (errorMessages) { + this.dialogService.error({ title: helptextApps.bulkActions.title, message: errorMessages }); + } + + this.toggleAppsChecked(false); + } + + private getErrorMessages(results: CoreBulkResponse[]): string { + const errors = results.filter((item) => item.error).map((item) => `
  • ${item.error}
  • `); + + return errors.length ? `
      ${errors.join('')}
    ` : ''; + } + + private selectAppForDetails(appId: string): void { + if (!this.dataSource.length) { + return; + } + + const selectedApp = appId && this.dataSource.find((app) => app.id === appId); + if (selectedApp) { + this.selectedApp = selectedApp; + this.cdr.markForCheck(); + return; + } + + this.selectFirstApp(); + } + + private selectFirstApp(): void { + const [firstApp] = this.dataSource; + if (firstApp.metadata.train && firstApp.id) { + this.location.replaceState(['/apps', 'installed', firstApp.metadata.train, firstApp.id].join('/')); + } else { + this.location.replaceState(['/apps', 'installed'].join('/')); + } + + this.selectedApp = firstApp; + this.cdr.markForCheck(); + } + + private resetSearch(): void { + this.onSearch(''); + } + + private redirectToInstalledAppsWithoutDetails(): void { + this.router.navigate(['/apps', 'installed'], { state: { hideMobileDetails: true } }); + } + + private redirectToAvailableApps(): void { + this.router.navigate(['/apps', 'available']); + } + + private listenForStatusUpdates(): void { + this.appService + .getInstalledAppsStatusUpdates() + .pipe(untilDestroyed(this)) + .subscribe((event) => { + const [name] = event.fields.arguments; + this.appJobs.set(name, event.fields); + this.sortChanged(this.sortingInfo); + this.cdr.markForCheck(); + }); + } + + getAppStats(name: string): Observable { + return this.appsStats.getStatsForApp(name); + } +} diff --git a/src/app/pages/apps/components/installed-apps/installed-apps.component.html b/src/app/pages/apps/components/installed-apps/installed-apps.component.html index 0f2a184467e..10db796216e 100644 --- a/src/app/pages/apps/components/installed-apps/installed-apps.component.html +++ b/src/app/pages/apps/components/installed-apps/installed-apps.component.html @@ -29,196 +29,28 @@ >{{ 'Discover Apps' | translate }}
    -
    -
    -
    -

    {{ 'Applications' | translate }}

    - - @if (hasCheckedApps) { -
    -
    - {{ checkedAppsNames.length }} - {{ 'Selected' | translate }} -
    - -
    - - -
    - - - - - - - -
    - } -
    - - - - - -
    -
    -
    - @for (app of filteredApps; track app.name) { - - } - - @if ((dataSource.length && !filteredApps.length) || (!dataSource.length && !isLoading())) { -
    - -
    - } -
    -
    -
    -
    - - @if (selectedApp) { -
    + + + + + {{ selectedApp?.name }} + + + + @if (selectedApp) { -
    - } -
    + } + + diff --git a/src/app/pages/apps/components/installed-apps/installed-apps.component.scss b/src/app/pages/apps/components/installed-apps/installed-apps.component.scss index 092c37f490a..ccba9dcb0c1 100644 --- a/src/app/pages/apps/components/installed-apps/installed-apps.component.scss +++ b/src/app/pages/apps/components/installed-apps/installed-apps.component.scss @@ -1,166 +1,7 @@ -@import 'scss-imports/variables'; -@import 'mixins/layout'; - -@include tree-node-with-details-container; - :host { display: block; } -.table-header { - align-items: flex-end; - color: var(--fg1); - display: flex; - justify-content: space-between; - margin-bottom: 12px; - min-height: 56px; - - h2 { - align-items: center; - display: flex; - margin-top: 20px; - min-height: 36px; - } - - .bulk { - align-items: center; - display: flex; - gap: 6px; - } -} - -.app-wrapper { - display: flex; - flex: 1; - flex-direction: column; - overflow: auto; -} - -.app-inner { - background-color: var(--bg2); - display: flex; - flex: 1; - flex-direction: column; - min-width: fit-content; - - @media (max-width: $breakpoint-tablet) { - min-height: fit-content; - min-width: unset; - overflow-x: auto; - } -} - -.no-apps { - color: var(--fg1); - display: flex; - justify-content: center; - margin: 15px; -} - -ix-app-row, -.app-header-row { - grid-template-columns: 5% minmax(18%, auto) 11% 4% 7% 11% 11% 13% 10%; - - @media (max-width: $breakpoint-tablet) { - grid-template-columns: 45px auto 0 0 0 0 0 0; - } -} - -.app-header-row { - align-items: center; - background: var(--bg1); - border-bottom: 1px solid var(--lines); - color: var(--fg2); - display: grid; - grid-gap: 8px; - min-height: 48px; - min-width: fit-content; - - position: sticky; - top: 0; - width: 100%; - z-index: 1; - - > div { - align-items: center; - display: flex; - font-weight: bold; - height: 100%; - justify-content: flex-start; - padding: 4px 0; - - @media (max-width: $breakpoint-tablet) { - display: none !important; - } - - &:first-child { - left: 0; - position: sticky; - - @media (max-width: $breakpoint-tablet) { - display: block !important; - } - } - - &:nth-child(2) { - @media (max-width: $breakpoint-tablet) { - display: flex !important; - } - } - } - - .app-update-header { - align-items: center; - display: flex; - gap: 4px; - - ix-icon { - color: var(--yellow); - font-size: 18px; - line-height: 1; - margin-left: 4px; - } - } -} - -ix-app-details-panel { - display: flex; - flex-direction: column; - width: 100%; -} - -.bulk-selected { - align-items: center; - align-self: flex-end; - display: inline-flex; - font-size: 16px; - gap: 4px; - height: 36px; -} - -.bulk-actions-container { - align-items: flex-end; - display: flex; - gap: 12px; -} - -.bulk-button-wrapper { - display: flex; - flex-direction: column; - - label { - color: var(--fg2); - font-size: 10px; - margin-bottom: 2px; - } - - button { - background-color: var(--bg1); - border: 1px solid var(--lines); - font-size: 12px; - } -} - .global-update { align-items: center; display: flex; diff --git a/src/app/pages/apps/components/installed-apps/installed-apps.component.spec.ts b/src/app/pages/apps/components/installed-apps/installed-apps.component.spec.ts index ae4f8c98b2c..c53b1081433 100644 --- a/src/app/pages/apps/components/installed-apps/installed-apps.component.spec.ts +++ b/src/app/pages/apps/components/installed-apps/installed-apps.component.spec.ts @@ -120,7 +120,7 @@ describe('InstalledAppsComponent', () => { beforeEach(() => { spectator = createComponent(); loader = TestbedHarnessEnvironment.loader(spectator.fixture); - spectator.component.dataSource = [app]; + spectator.component.installedAppsList().dataSource = [app]; applicationsService = spectator.inject(ApplicationsService); }); @@ -128,7 +128,7 @@ describe('InstalledAppsComponent', () => { const rows = spectator.queryAll(AppRowComponent); expect(rows).toHaveLength(1); - expect(rows[0].app).toEqual(app); + expect(rows[0].app()).toEqual(app); }); it('shows details', () => { @@ -139,12 +139,14 @@ describe('InstalledAppsComponent', () => { }); it('starts application', () => { - spectator.query(AppRowComponent).startApp.emit(); + spectator.detectChanges(); + spectator.query(AppDetailsPanelComponent).startApp.emit(); expect(applicationsService.startApplication).toHaveBeenCalledWith('test-app'); }); it('stops application', () => { - spectator.query(AppRowComponent).stopApp.emit(); + spectator.detectChanges(); + spectator.query(AppDetailsPanelComponent).stopApp.emit(); expect(applicationsService.stopApplication).toHaveBeenCalledWith('test-app'); }); @@ -159,7 +161,7 @@ describe('InstalledAppsComponent', () => { afterClosed: () => of({ removeVolumes: true, removeImages: true }), } as MatDialogRef); - spectator.component.selection.select(app.name); + spectator.component.installedAppsList().selection.select(app.id); const menu = await loader.getHarness(MatMenuHarness.with({ triggerText: 'Select action' })); await menu.open(); @@ -167,12 +169,12 @@ describe('InstalledAppsComponent', () => { expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( AppDeleteDialogComponent, - { data: { name: 'test-app', showRemoveVolumes: true } }, + { data: { name: app.id, showRemoveVolumes: true } }, ); expect(spectator.inject(ApiService).job).toHaveBeenCalledWith( 'core.bulk', - ['app.delete', [[app.name, { remove_images: true, remove_ix_volumes: true }]]], + ['app.delete', [[app.id, { remove_images: true, remove_ix_volumes: true }]]], ); }); }); diff --git a/src/app/pages/apps/components/installed-apps/installed-apps.component.ts b/src/app/pages/apps/components/installed-apps/installed-apps.component.ts index a25517724ec..1e78c84b74c 100644 --- a/src/app/pages/apps/components/installed-apps/installed-apps.component.ts +++ b/src/app/pages/apps/components/installed-apps/installed-apps.component.ts @@ -1,83 +1,23 @@ -import { SelectionModel } from '@angular/cdk/collections'; -import { BreakpointObserver, BreakpointState, Breakpoints } from '@angular/cdk/layout'; -import { AsyncPipe, Location } from '@angular/common'; import { Component, ChangeDetectionStrategy, - OnInit, - ChangeDetectorRef, - AfterViewInit, - Inject, signal, + viewChild, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { MatAnchor, MatButton } from '@angular/material/button'; -import { MatCheckbox } from '@angular/material/checkbox'; -import { MatDialog } from '@angular/material/dialog'; -import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; -import { MatSort, MatSortHeader, Sort } from '@angular/material/sort'; -import { MatColumnDef } from '@angular/material/table'; -import { MatTooltip } from '@angular/material/tooltip'; -import { - ActivatedRoute, NavigationEnd, NavigationStart, Router, - RouterLink, -} from '@angular/router'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { Store } from '@ngrx/store'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { - combineLatest, filter, - forkJoin, - Observable, - switchMap, -} from 'rxjs'; -import { DetailsHeightDirective } from 'app/directives/details-height/details-height.directive'; +import { RouterLink } from '@angular/router'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { TranslateModule } from '@ngx-translate/core'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; -import { UiSearchDirective } from 'app/directives/ui-search.directive'; -import { AppState } from 'app/enums/app-state.enum'; -import { EmptyType } from 'app/enums/empty-type.enum'; import { Role } from 'app/enums/role.enum'; -import { WINDOW } from 'app/helpers/window.helper'; -import { helptextApps } from 'app/helptext/apps/apps'; -import { App, AppStartQueryParams, AppStats } from 'app/interfaces/app.interface'; -import { CoreBulkResponse } from 'app/interfaces/core-bulk.interface'; -import { EmptyConfig } from 'app/interfaces/empty-config.interface'; -import { Job } from 'app/interfaces/job.interface'; -import { DialogService } from 'app/modules/dialog/dialog.service'; -import { EmptyComponent } from 'app/modules/empty/empty.component'; -import { SearchInput1Component } from 'app/modules/forms/search-input1/search-input1.component'; +import { App } from 'app/interfaces/app.interface'; import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; -import { SortDirection } from 'app/modules/ix-table/enums/sort-direction.enum'; -import { selectJob } from 'app/modules/jobs/store/job.selectors'; -import { AppLoaderService } from 'app/modules/loader/app-loader.service'; -import { FakeProgressBarComponent } from 'app/modules/loader/components/fake-progress-bar/fake-progress-bar.component'; +import { MasterDetailViewComponent } from 'app/modules/master-detail-view/master-detail-view.component'; import { PageHeaderComponent } from 'app/modules/page-header/page-title-header/page-header.component'; -import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { TestDirective } from 'app/modules/test-id/test.directive'; -import { AppDeleteDialogComponent } from 'app/pages/apps/components/app-delete-dialog/app-delete-dialog.component'; -import { AppDeleteDialogInputData, AppDeleteDialogOutputData } from 'app/pages/apps/components/app-delete-dialog/app-delete-dialog.interface'; -import { AppBulkUpgradeComponent } from 'app/pages/apps/components/installed-apps/app-bulk-upgrade/app-bulk-upgrade.component'; import { AppDetailsPanelComponent } from 'app/pages/apps/components/installed-apps/app-details-panel/app-details-panel.component'; -import { AppRowComponent } from 'app/pages/apps/components/installed-apps/app-row/app-row.component'; import { AppSettingsButtonComponent } from 'app/pages/apps/components/installed-apps/app-settings-button/app-settings-button.component'; import { DockerStatusComponent } from 'app/pages/apps/components/installed-apps/docker-status/docker-status.component'; -import { installedAppsElements } from 'app/pages/apps/components/installed-apps/installed-apps.elements'; -import { ApplicationsService } from 'app/pages/apps/services/applications.service'; -import { AppsStatsService } from 'app/pages/apps/store/apps-stats.service'; -import { DockerStore } from 'app/pages/apps/store/docker.store'; -import { InstalledAppsStore } from 'app/pages/apps/store/installed-apps-store.service'; -import { ErrorHandlerService } from 'app/services/error-handler.service'; -import { ApiService } from 'app/services/websocket/api.service'; -import { AppState as WebuiAppState } from 'app/store'; - -enum SortableField { - Application = 'application', - State = 'state', - Updates = 'updates', -} - -function doSortCompare(a: number | string, b: number | string, isAsc: boolean): number { - return (a < b ? -1 : 1) * (isAsc ? 1 : -1); -} +import { InstalledAppsListComponent } from 'app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component'; @UntilDestroy() @Component({ @@ -97,483 +37,37 @@ function doSortCompare(a: number | string, b: number | string, isAsc: boolean): AppSettingsButtonComponent, RouterLink, MatAnchor, - UiSearchDirective, - MatMenuTrigger, - MatMenu, - MatMenuItem, - FakeProgressBarComponent, - SearchInput1Component, - MatSort, - AsyncPipe, - MatCheckbox, - MatColumnDef, - MatSortHeader, - AppRowComponent, - EmptyComponent, - MatTooltip, - DetailsHeightDirective, AppDetailsPanelComponent, + MasterDetailViewComponent, + InstalledAppsListComponent, ], }) -export class InstalledAppsComponent implements OnInit, AfterViewInit { - protected readonly searchableElements = installedAppsElements; - - readonly isLoading = toSignal(this.installedAppsStore.isLoading$, { requireSync: true }); - - readonly isMobileView = signal(false); - readonly showMobileDetails = signal(false); - - dataSource: App[] = []; - selectedApp: App; - filterString = ''; - appJobs = new Map>(); - selection = new SelectionModel(true, []); - sortingInfo: Sort = { - active: SortableField.Application, - direction: SortDirection.Asc, - }; - - readonly sortableField = SortableField; - - entityEmptyConf: EmptyConfig = { - type: EmptyType.Loading, - large: false, - title: helptextApps.message.loading, - }; - - get filteredApps(): App[] { - return this.dataSource - .filter((app) => app?.name?.toLocaleLowerCase().includes(this.filterString.toLocaleLowerCase())); - } - - get allAppsChecked(): boolean { - return this.selection.selected.length === this.filteredApps.length; - } +export class InstalledAppsComponent { + readonly installedAppsList = viewChild.required(InstalledAppsListComponent); - get hasCheckedApps(): boolean { - return this.checkedAppsNames.length > 0; + get selectedApp(): App | undefined { + return this.installedAppsList().selectedApp; } get appsUpdateAvailable(): number { - return this.dataSource - .filter((app) => app.upgrade_available).length; + return this.installedAppsList().appsUpdateAvailable; } get hasUpdates(): boolean { - return this.dataSource.some((app) => app.upgrade_available); - } - - get checkedAppsNames(): string[] { - return this.selection.selected; - } - - get checkedApps(): App[] { - return this.checkedAppsNames.map((name) => this.dataSource.find((app) => app.name === name)); - } - - get isBulkStartDisabled(): boolean { - return this.checkedApps.every( - (app) => [AppState.Running, AppState.Deploying].includes(app.state), - ); - } - - get isBulkStopDisabled(): boolean { - return this.checkedApps.every( - (app) => [AppState.Stopped, AppState.Crashed].includes(app.state), - ); - } - - get isBulkUpgradeDisabled(): boolean { - return !this.checkedApps.some((app) => app.upgrade_available); - } - - get activeCheckedApps(): App[] { - return this.dataSource.filter( - (app) => [AppState.Running, AppState.Deploying].includes(app.state) && this.selection.isSelected(app.id), - ); - } - - get stoppedCheckedApps(): App[] { - return this.dataSource.filter( - (app) => [AppState.Stopped, AppState.Crashed].includes(app.state) && this.selection.isSelected(app.id), - ); + return this.installedAppsList().hasUpdates; } protected readonly requiredRoles = [Role.AppsWrite]; - constructor( - private api: ApiService, - private appService: ApplicationsService, - private cdr: ChangeDetectorRef, - private activatedRoute: ActivatedRoute, - private router: Router, - private matDialog: MatDialog, - private dialogService: DialogService, - private snackbar: SnackbarService, - private translate: TranslateService, - private installedAppsStore: InstalledAppsStore, - private dockerStore: DockerStore, - private breakpointObserver: BreakpointObserver, - private errorHandler: ErrorHandlerService, - private store$: Store, - private location: Location, - private appsStats: AppsStatsService, - private loader: AppLoaderService, - @Inject(WINDOW) private window: Window, - ) { - this.router.events - .pipe( - filter((event) => event instanceof NavigationStart || event instanceof NavigationEnd), - untilDestroyed(this), - ) - .subscribe(() => { - if (this.router.getCurrentNavigation()?.extras?.state?.hideMobileDetails) { - this.closeMobileDetails(); - this.selectedApp = undefined; - this.cdr.markForCheck(); - } - }); - } - - ngOnInit(): void { - this.loadInstalledApps(); - this.listenForStatusUpdates(); - } - - ngAfterViewInit(): void { - this.breakpointObserver - .observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium]) - .pipe(untilDestroyed(this)) - .subscribe((state: BreakpointState) => { - if (state.matches) { - this.isMobileView.set(true); - } else { - this.isMobileView.set(false); - this.closeMobileDetails(); - } - this.cdr.markForCheck(); - }); - } - - closeMobileDetails(): void { - this.showMobileDetails.set(false); - } - - viewDetails(app: App): void { - this.selectAppForDetails(app.id); - - this.router.navigate(['/apps/installed', app.metadata.train, app.id]); - - if (this.isMobileView()) { - this.showMobileDetails.set(true); - - setTimeout(() => (this.window.document.getElementsByClassName('mobile-back-button')[0] as HTMLElement).focus(), 0); - } - } - - onSearch(query: string): void { - this.filterString = query; - - if (!this.filteredApps.length) { - this.showLoadStatus(EmptyType.NoSearchResults); - } - } - - toggleAppsChecked(checked: boolean): void { - if (checked) { - this.dataSource.forEach((app) => this.selection.select(app.id)); - } else { - this.selection.clear(); - } - } - - showLoadStatus(type: EmptyType.FirstUse | EmptyType.NoPageData | EmptyType.Errors | EmptyType.NoSearchResults): void { - switch (type) { - case EmptyType.FirstUse: - case EmptyType.NoPageData: - this.entityEmptyConf.title = helptextApps.message.no_installed; - this.entityEmptyConf.message = this.translate.instant('Applications you install will automatically appear here. Click below and browse available apps to get started.'); - this.entityEmptyConf.button = { - label: this.translate.instant('Check Available Apps'), - action: () => this.redirectToAvailableApps(), - }; - break; - case EmptyType.Errors: - this.entityEmptyConf.title = helptextApps.message.not_running; - this.entityEmptyConf.message = undefined; - break; - case EmptyType.NoSearchResults: - this.entityEmptyConf.title = helptextApps.message.no_search_result; - this.entityEmptyConf.message = undefined; - this.entityEmptyConf.button = { - label: this.translate.instant('Reset Search'), - action: () => { - this.resetSearch(); - this.cdr.markForCheck(); - }, - }; - break; - } - - this.entityEmptyConf.type = type; - } - - loadInstalledApps(): void { - this.cdr.markForCheck(); - - combineLatest([ - this.dockerStore.selectedPool$, - this.dockerStore.isDockerStarted$, - this.installedAppsStore.installedApps$, - ]).pipe( - filter(([pool]) => { - if (!pool) { - this.dataSource = []; - this.showLoadStatus(EmptyType.FirstUse); - this.cdr.markForCheck(); - this.redirectToInstalledAppsWithoutDetails(); - } - return !!pool; - }), - filter(([,dockerStarted]) => { - if (!dockerStarted) { - this.dataSource = []; - this.showLoadStatus(EmptyType.Errors); - this.cdr.markForCheck(); - this.redirectToInstalledAppsWithoutDetails(); - } - return !!dockerStarted; - }), - filter(([,, apps]) => { - if (!apps.length) { - this.dataSource = []; - this.showLoadStatus(EmptyType.NoPageData); - this.cdr.markForCheck(); - this.redirectToInstalledAppsWithoutDetails(); - } - return !!apps.length; - }), - untilDestroyed(this), - ).subscribe({ - next: ([,, apps]) => { - this.sortChanged(this.sortingInfo, apps); - this.selectAppForDetails(this.activatedRoute.snapshot.paramMap.get('appId')); - this.cdr.markForCheck(); - }, - }); - } - start(name: string): void { - this.appService.startApplication(name) - .pipe( - this.errorHandler.catchError(), - untilDestroyed(this), - ) - .subscribe((job: Job) => { - this.appJobs.set(name, job); - this.sortChanged(this.sortingInfo); - this.cdr.markForCheck(); - }); + this.installedAppsList().start(name); } stop(name: string): void { - this.appService.stopApplication(name) - .pipe( - this.errorHandler.catchError(), - untilDestroyed(this), - ) - .subscribe({ - next: (job: Job) => { - this.appJobs.set(name, job); - this.sortChanged(this.sortingInfo); - this.cdr.markForCheck(); - }, - }); - } - - restart(name: string): void { - this.appService.restartApplication(name) - .pipe( - this.errorHandler.catchError(), - untilDestroyed(this), - ) - .subscribe((job: Job) => { - this.appJobs.set(name, job); - this.sortChanged(this.sortingInfo); - this.cdr.markForCheck(); - }); - } - - openStatusDialog(name: string): void { - if (!this.appJobs.has(name)) { - return; - } - const job$ = this.store$.select(selectJob(this.appJobs.get(name).id)); - this.dialogService.jobDialog(job$, { title: name, canMinimize: true }) - .afterClosed() - .pipe(this.errorHandler.catchError(), untilDestroyed(this)) - .subscribe(); - } - - onBulkStart(): void { - this.stoppedCheckedApps.forEach((app) => this.start(app.name)); - this.snackbar.success(this.translate.instant(helptextApps.bulkActions.finished)); - this.toggleAppsChecked(false); - } - - onBulkStop(): void { - this.activeCheckedApps.forEach((app) => this.stop(app.name)); - this.snackbar.success(this.translate.instant(helptextApps.bulkActions.finished)); - this.toggleAppsChecked(false); + this.installedAppsList().stop(name); } onBulkUpgrade(updateAll = false): void { - const apps = this.dataSource.filter((app) => ( - updateAll ? app.upgrade_available : this.selection.isSelected(app.id) - )); - this.matDialog.open(AppBulkUpgradeComponent, { data: apps }) - .afterClosed() - .pipe(untilDestroyed(this)) - .subscribe(() => { - this.toggleAppsChecked(false); - }); - } - - onBulkDelete(): void { - forkJoin(this.checkedAppsNames.map((appName) => this.appService.checkIfAppIxVolumeExists(appName))) - .pipe( - this.loader.withLoader(), - switchMap((ixVolumesExist) => { - return this.matDialog.open< - AppDeleteDialogComponent, - AppDeleteDialogInputData, - AppDeleteDialogOutputData - >(AppDeleteDialogComponent, { - data: { - name: this.checkedAppsNames.join(', '), - showRemoveVolumes: ixVolumesExist.some(Boolean), - }, - }).afterClosed(); - }), - filter(Boolean), - switchMap(({ removeVolumes, removeImages }) => this.executeBulkDeletion(removeVolumes, removeImages)), - this.errorHandler.catchError(), - untilDestroyed(this), - ) - .subscribe((job: Job) => this.handleDeletionResult(job)); - } - - sortChanged(sort: Sort, apps?: App[]): void { - this.sortingInfo = sort; - - this.dataSource = (apps || this.dataSource).sort((a, b) => { - const isAsc = sort.direction === SortDirection.Asc; - - switch (sort.active as SortableField) { - case SortableField.Application: - return doSortCompare(a.name, b.name, isAsc); - case SortableField.State: - return doSortCompare(a.state, b.state, isAsc); - case SortableField.Updates: - return doSortCompare( - a.upgrade_available ? 1 : 0, - b.upgrade_available ? 1 : 0, - isAsc, - ); - default: - return doSortCompare(a.name, b.name, isAsc); - } - }); - } - - private executeBulkDeletion( - removeVolumes = false, - removeImages = true, - ): Observable> { - const bulkDeletePayload = this.checkedAppsNames.map((name) => [ - name, - { remove_images: removeImages, remove_ix_volumes: removeVolumes }, - ]); - - return this.dialogService.jobDialog( - this.api.job('core.bulk', ['app.delete', bulkDeletePayload]), - { title: helptextApps.apps.delete_dialog.job }, - ).afterClosed(); - } - - private handleDeletionResult(job: Job): void { - if (!this.dataSource.length) { - this.redirectToInstalledAppsWithoutDetails(); - } - - this.dialogService.closeAllDialogs(); - const errorMessages = this.getErrorMessages(job.result); - - if (errorMessages) { - this.dialogService.error({ title: helptextApps.bulkActions.title, message: errorMessages }); - } - - this.toggleAppsChecked(false); - } - - private getErrorMessages(results: CoreBulkResponse[]): string { - const errors = results.filter((item) => item.error).map((item) => `
  • ${item.error}
  • `); - - return errors.length ? `
      ${errors.join('')}
    ` : ''; - } - - private selectAppForDetails(appId: string): void { - if (!this.dataSource.length) { - return; - } - - const selectedApp = appId && this.dataSource.find((app) => app.id === appId); - if (selectedApp) { - this.selectedApp = selectedApp; - this.cdr.markForCheck(); - return; - } - - this.selectFirstApp(); - } - - private selectFirstApp(): void { - const [firstApp] = this.dataSource; - if (firstApp.metadata.train && firstApp.id) { - this.location.replaceState(['/apps', 'installed', firstApp.metadata.train, firstApp.id].join('/')); - } else { - this.location.replaceState(['/apps', 'installed'].join('/')); - } - - this.selectedApp = firstApp; - this.cdr.markForCheck(); - } - - private resetSearch(): void { - this.onSearch(''); - } - - private redirectToInstalledAppsWithoutDetails(): void { - this.router.navigate(['/apps', 'installed'], { state: { hideMobileDetails: true } }); - } - - private redirectToAvailableApps(): void { - this.router.navigate(['/apps', 'available']); - } - - private listenForStatusUpdates(): void { - this.appService - .getInstalledAppsStatusUpdates() - .pipe(untilDestroyed(this)) - .subscribe((event) => { - const [name] = event.fields.arguments; - this.appJobs.set(name, event.fields); - this.sortChanged(this.sortingInfo); - this.cdr.markForCheck(); - }); - } - - getAppStats(name: string): Observable { - return this.appsStats.getStatsForApp(name); + this.installedAppsList().onBulkUpgrade(updateAll); } } diff --git a/src/app/pages/apps/components/select-pool-dialog/select-pool-dialog.component.ts b/src/app/pages/apps/components/select-pool-dialog/select-pool-dialog.component.ts index ce1b4c313c8..5d25681cbaf 100644 --- a/src/app/pages/apps/components/select-pool-dialog/select-pool-dialog.component.ts +++ b/src/app/pages/apps/components/select-pool-dialog/select-pool-dialog.component.ts @@ -105,7 +105,7 @@ export class SelectPoolDialogComponent implements OnInit { this.showNoPoolsWarning(); } }, - error: (error) => { + error: (error: unknown) => { this.errorHandler.showErrorModal(error); this.dialogRef.close(false); }, diff --git a/src/app/pages/apps/store/apps-store.service.ts b/src/app/pages/apps/store/apps-store.service.ts index 666de23afc9..47cbe6d016f 100644 --- a/src/app/pages/apps/store/apps-store.service.ts +++ b/src/app/pages/apps/store/apps-store.service.ts @@ -95,7 +95,7 @@ export class AppsStore extends ComponentStore { }; }); }), - catchError((error) => { + catchError((error: unknown) => { this.handleError(error); return EMPTY; }), @@ -104,7 +104,7 @@ export class AppsStore extends ComponentStore { private loadLatestApps(): Observable { return this.appsService.getLatestApps().pipe( - catchError((error) => { + catchError((error: unknown) => { this.handleError(error); return of([]); }), @@ -121,7 +121,7 @@ export class AppsStore extends ComponentStore { private loadAvailableApps(): Observable { return this.appsService.getAvailableApps().pipe( - catchError((error) => { + catchError((error: unknown) => { this.handleError(error); return of([]); }), @@ -141,7 +141,7 @@ export class AppsStore extends ComponentStore { private loadCategories(): Observable { return this.appsService.getAllAppsCategories().pipe( - catchError((error) => { + catchError((error: unknown) => { this.handleError(error); return of([]); }), diff --git a/src/app/pages/apps/store/installed-apps-store.service.ts b/src/app/pages/apps/store/installed-apps-store.service.ts index 4dd5828fe4d..6e707c20553 100644 --- a/src/app/pages/apps/store/installed-apps-store.service.ts +++ b/src/app/pages/apps/store/installed-apps-store.service.ts @@ -5,7 +5,7 @@ import { EMPTY, Observable, Subscription, catchError, combineLatest, filter, of, switchMap, tap, } from 'rxjs'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { tapOnce } from 'app/helpers/operators/tap-once.operator'; import { ApiEvent } from 'app/interfaces/api-message.interface'; import { App } from 'app/interfaces/app.interface'; @@ -119,11 +119,11 @@ export class InstalledAppsStore extends ComponentStore imple private handleApiEvent(apiEvent: ApiEvent): void { switch (apiEvent.msg) { - case IncomingApiMessageType.Removed: + case CollectionChangeType.Removed: this.handleRemovedEvent(apiEvent); break; - case IncomingApiMessageType.Added: - case IncomingApiMessageType.Changed: + case CollectionChangeType.Added: + case CollectionChangeType.Changed: this.handleAddedOrUpdatedEvent(apiEvent); break; default: @@ -165,7 +165,7 @@ export class InstalledAppsStore extends ComponentStore imple } this.patchState((state: InstalledAppsState): InstalledAppsState => { - if (apiEvent.msg === IncomingApiMessageType.Added) { + if (apiEvent.msg === CollectionChangeType.Added) { return { ...state, installedApps: [...state.installedApps, app] }; } diff --git a/src/app/pages/audit/utils/get-log-important-data.utils.spec.ts b/src/app/pages/audit/utils/get-log-important-data.utils.spec.ts index bf645d2d457..a82e6e8c836 100644 --- a/src/app/pages/audit/utils/get-log-important-data.utils.spec.ts +++ b/src/app/pages/audit/utils/get-log-important-data.utils.spec.ts @@ -164,6 +164,16 @@ const middlewareEntries = { }, }, } as AuditEntry, + failedAuthentication: { + service: AuditService.Middleware, + event: AuditEvent.Authentication, + event_data: { + error: 'Some error', + credentials: { + credentials: CredentialType.LoginPassword, + }, + }, + } as AuditEntry, methodCall: { service: AuditService.Middleware, event: AuditEvent.MethodCall, @@ -273,6 +283,10 @@ describe('get important data from log', () => { expect(getLogImportantData(middlewareEntries.authentication, translate)).toBe('Credentials: Password Login'); }); + it('returns value for failed authentication', () => { + expect(getLogImportantData(middlewareEntries.failedAuthentication, translate)).toBe('Failed Authentication: Password Login'); + }); + it('returns value for MethodCall type', () => { expect(getLogImportantData(middlewareEntries.methodCall, translate)).toBe('Delete files'); }); diff --git a/src/app/pages/audit/utils/get-log-important-data.utils.ts b/src/app/pages/audit/utils/get-log-important-data.utils.ts index cef7e160a82..318174e881f 100644 --- a/src/app/pages/audit/utils/get-log-important-data.utils.ts +++ b/src/app/pages/audit/utils/get-log-important-data.utils.ts @@ -29,17 +29,19 @@ function getMiddlewareLogImportantData(log: MiddlewareAuditEntry, translate: Tra case AuditEvent.MethodCall: return log.event_data?.description || log.event_data?.method; case AuditEvent.Authentication: { - const credentialType = log.event_data?.credentials.credentials; - const credentialTypeKey = credentialTypeLabels.get(credentialType); + const credentialType = log.event_data?.credentials?.credentials; + const credentialTypeLabel = credentialTypeLabels.has(credentialType) + ? translate.instant(credentialTypeLabels.get(credentialType)) + : credentialType; if (log.event_data?.error) { return translate.instant(T('Failed Authentication: {credentials}'), { - credentials: credentialType ? translate.instant(credentialTypeKey) : credentialType, + credentials: credentialType ? credentialTypeLabel : credentialType, }); } return translate.instant(T('Credentials: {credentials}'), { - credentials: credentialType ? translate.instant(credentialTypeKey) : credentialType, + credentials: credentialType ? credentialTypeLabel : credentialType, }); } default: diff --git a/src/app/pages/credentials/backup-credentials/ssh-connection-form/ssh-connection-form.component.ts b/src/app/pages/credentials/backup-credentials/ssh-connection-form/ssh-connection-form.component.ts index 4295c725873..207bb4f3491 100644 --- a/src/app/pages/credentials/backup-credentials/ssh-connection-form/ssh-connection-form.component.ts +++ b/src/app/pages/credentials/backup-credentials/ssh-connection-form/ssh-connection-form.component.ts @@ -17,9 +17,9 @@ import { import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { Role } from 'app/enums/role.enum'; import { SshConnectionsSetupMethod } from 'app/enums/ssh-connections-setup-method.enum'; +import { extractApiError } from 'app/helpers/api.helper'; import { idNameArrayToOptions } from 'app/helpers/operators/options.operators'; import { helptextSshConnections } from 'app/helptext/system/ssh-connections'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { KeychainCredential, KeychainCredentialUpdate, @@ -274,8 +274,9 @@ export class SshConnectionFormComponent implements OnInit { } return this.api.call('keychaincredential.setup_ssh_connection', [params]).pipe( - catchError((error: ApiError) => { - if (error.errname.includes(sslCertificationError) || error.reason.includes(sslCertificationError)) { + catchError((error: unknown) => { + const apiError = extractApiError(error); + if (apiError?.errname?.includes(sslCertificationError) || apiError?.reason?.includes(sslCertificationError)) { return this.dialogService.error(this.errorHandler.parseError(error)).pipe( switchMap(() => { return this.dialogService.confirm({ diff --git a/src/app/pages/credentials/certificates-dash/certificate-acme-add/certificate-acme-add.component.ts b/src/app/pages/credentials/certificates-dash/certificate-acme-add/certificate-acme-add.component.ts index a0f18cca8b0..c92ed42737b 100644 --- a/src/app/pages/credentials/certificates-dash/certificate-acme-add/certificate-acme-add.component.ts +++ b/src/app/pages/credentials/certificates-dash/certificate-acme-add/certificate-acme-add.component.ts @@ -137,7 +137,7 @@ export class CertificateAcmeAddComponent { this.isLoading = false; this.cdr.markForCheck(); }, - error: (error) => { + error: (error: unknown) => { this.formErrorHandler.handleValidationErrors(error, this.form); this.isLoading = false; this.cdr.markForCheck(); diff --git a/src/app/pages/credentials/certificates-dash/certificate-authority-list/certificate-authority-list.component.ts b/src/app/pages/credentials/certificates-dash/certificate-authority-list/certificate-authority-list.component.ts index 5e7950308ec..b99f04d435d 100644 --- a/src/app/pages/credentials/certificates-dash/certificate-authority-list/certificate-authority-list.component.ts +++ b/src/app/pages/credentials/certificates-dash/certificate-authority-list/certificate-authority-list.component.ts @@ -1,5 +1,4 @@ import { AsyncPipe } from '@angular/common'; -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, OnInit, output, @@ -19,10 +18,7 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r import { UiSearchDirective } from 'app/directives/ui-search.directive'; import { Role } from 'app/enums/role.enum'; import { helptextSystemCa } from 'app/helptext/system/ca'; -import { helptextSystemCertificates } from 'app/helptext/system/certificates'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { CertificateAuthority } from 'app/interfaces/certificate-authority.interface'; -import { Job } from 'app/interfaces/job.interface'; import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { EmptyService } from 'app/modules/empty/empty.service'; @@ -240,21 +236,15 @@ export class CertificateAuthorityListComponent implements OnInit { const mimetype = 'application/x-x509-user-cert'; this.download .streamDownloadFile(url, fileName, mimetype) - .pipe(untilDestroyed(this)) - .subscribe({ - next: (file) => { - this.download.downloadBlob(file, fileName); - }, - error: (error: HttpErrorResponse) => { - this.dialogService.error({ - title: helptextSystemCertificates.list.download_error_dialog.title, - message: helptextSystemCertificates.list.download_error_dialog.cert_message, - backtrace: `${error.status} - ${error.statusText}`, - }); - }, + .pipe( + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe((file) => { + this.download.downloadBlob(file, fileName); }); }, - error: (err: ApiError | Job) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); @@ -267,18 +257,12 @@ export class CertificateAuthorityListComponent implements OnInit { const mimetype = 'text/plain'; this.download .streamDownloadFile(url, keyName, mimetype) - .pipe(untilDestroyed(this)) - .subscribe({ - next: (file) => { - this.download.downloadBlob(file, keyName); - }, - error: (error: HttpErrorResponse) => { - this.dialogService.error({ - title: helptextSystemCertificates.list.download_error_dialog.title, - message: helptextSystemCertificates.list.download_error_dialog.key_message, - backtrace: `${error.status} - ${error.statusText}`, - }); - }, + .pipe( + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe((file) => { + this.download.downloadBlob(file, keyName); }); }, error: (err: unknown) => { @@ -302,7 +286,7 @@ export class CertificateAuthorityListComponent implements OnInit { filter(Boolean), switchMap(() => { return this.api.call('certificateauthority.update', [certificate.id, { revoked: true }]).pipe( - catchError((error) => { + catchError((error: unknown) => { this.dialogService.error(this.errorHandler.parseError(error)); return EMPTY; }), diff --git a/src/app/pages/credentials/certificates-dash/certificate-list/certificate-list.component.ts b/src/app/pages/credentials/certificates-dash/certificate-list/certificate-list.component.ts index 6361d430dd1..9054dab8614 100644 --- a/src/app/pages/credentials/certificates-dash/certificate-list/certificate-list.component.ts +++ b/src/app/pages/credentials/certificates-dash/certificate-list/certificate-list.component.ts @@ -1,5 +1,4 @@ import { AsyncPipe } from '@angular/common'; -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, OnInit, output, } from '@angular/core'; @@ -15,11 +14,8 @@ import { import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { UiSearchDirective } from 'app/directives/ui-search.directive'; import { Role } from 'app/enums/role.enum'; -import { helptextSystemCertificates } from 'app/helptext/system/certificates'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { Certificate } from 'app/interfaces/certificate.interface'; import { DialogWithSecondaryCheckboxResult } from 'app/interfaces/dialog.interface'; -import { Job } from 'app/interfaces/job.interface'; import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { EmptyService } from 'app/modules/empty/empty.service'; @@ -235,21 +231,15 @@ export class CertificateListComponent implements OnInit { const mimetype = 'application/x-x509-user-cert'; this.download .streamDownloadFile(url, fileName, mimetype) - .pipe(untilDestroyed(this)) - .subscribe({ - next: (file) => { - this.download.downloadBlob(file, fileName); - }, - error: (error: HttpErrorResponse) => { - this.dialogService.error({ - title: helptextSystemCertificates.list.download_error_dialog.title, - message: helptextSystemCertificates.list.download_error_dialog.cert_message, - backtrace: `${error.status} - ${error.statusText}`, - }); - }, + .pipe( + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe((file) => { + this.download.downloadBlob(file, fileName); }); }, - error: (err: ApiError | Job) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); @@ -263,17 +253,8 @@ export class CertificateListComponent implements OnInit { this.download .streamDownloadFile(url, keyName, mimetype) .pipe(untilDestroyed(this)) - .subscribe({ - next: (file) => { - this.download.downloadBlob(file, keyName); - }, - error: (error: HttpErrorResponse) => { - this.dialogService.error({ - title: helptextSystemCertificates.list.download_error_dialog.title, - message: helptextSystemCertificates.list.download_error_dialog.key_message, - backtrace: `${error.status} - ${error.statusText}`, - }); - }, + .subscribe((file) => { + this.download.downloadBlob(file, keyName); }); }, error: (err: unknown) => { diff --git a/src/app/pages/credentials/certificates-dash/csr-list/csr-list.component.ts b/src/app/pages/credentials/certificates-dash/csr-list/csr-list.component.ts index 93d7c548ffc..af82b7130d8 100644 --- a/src/app/pages/credentials/certificates-dash/csr-list/csr-list.component.ts +++ b/src/app/pages/credentials/certificates-dash/csr-list/csr-list.component.ts @@ -1,5 +1,4 @@ import { AsyncPipe } from '@angular/common'; -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, OnInit, output, } from '@angular/core'; @@ -14,11 +13,8 @@ import { import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { UiSearchDirective } from 'app/directives/ui-search.directive'; import { Role } from 'app/enums/role.enum'; -import { helptextSystemCertificates } from 'app/helptext/system/certificates'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { Certificate } from 'app/interfaces/certificate.interface'; import { DialogWithSecondaryCheckboxResult } from 'app/interfaces/dialog.interface'; -import { Job } from 'app/interfaces/job.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { EmptyService } from 'app/modules/empty/empty.service'; import { iconMarker } from 'app/modules/ix-icon/icon-marker.util'; @@ -217,21 +213,15 @@ export class CertificateSigningRequestsListComponent implements OnInit { const mimetype = 'application/x-x509-user-cert'; this.download .streamDownloadFile(url, fileName, mimetype) - .pipe(untilDestroyed(this)) - .subscribe({ - next: (file) => { - this.download.downloadBlob(file, fileName); - }, - error: (error: HttpErrorResponse) => { - this.dialogService.error({ - title: helptextSystemCertificates.list.download_error_dialog.title, - message: helptextSystemCertificates.list.download_error_dialog.cert_message, - backtrace: `${error.status} - ${error.statusText}`, - }); - }, + .pipe( + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe((file) => { + this.download.downloadBlob(file, fileName); }); }, - error: (err: ApiError | Job) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); @@ -244,18 +234,12 @@ export class CertificateSigningRequestsListComponent implements OnInit { const mimetype = 'text/plain'; this.download .streamDownloadFile(url, keyName, mimetype) - .pipe(untilDestroyed(this)) - .subscribe({ - next: (file) => { - this.download.downloadBlob(file, keyName); - }, - error: (error: HttpErrorResponse) => { - this.dialogService.error({ - title: helptextSystemCertificates.list.download_error_dialog.title, - message: helptextSystemCertificates.list.download_error_dialog.key_message, - backtrace: `${error.status} - ${error.statusText}`, - }); - }, + .pipe( + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe((file) => { + this.download.downloadBlob(file, keyName); }); }, error: (err: unknown) => { diff --git a/src/app/pages/credentials/groups/group-members/group-members.component.ts b/src/app/pages/credentials/groups/group-members/group-members.component.ts index 155eb671670..bdd4e262964 100644 --- a/src/app/pages/credentials/groups/group-members/group-members.component.ts +++ b/src/app/pages/credentials/groups/group-members/group-members.component.ts @@ -101,7 +101,7 @@ export class GroupMembersComponent implements OnInit { this.isLoading.set(false); this.router.navigate(['/', 'credentials', 'groups']); }, - error: (error) => { + error: (error: unknown) => { this.isLoading.set(false); this.dialog.error(this.errorHandler.parseError(error)); }, diff --git a/src/app/pages/credentials/groups/privilege/privilege-list/privilege-list.component.ts b/src/app/pages/credentials/groups/privilege/privilege-list/privilege-list.component.ts index ef4e5aa2a87..e3518b041e3 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-list/privilege-list.component.ts +++ b/src/app/pages/credentials/groups/privilege/privilege-list/privilege-list.component.ts @@ -185,7 +185,7 @@ export class PrivilegeListComponent implements OnInit { next: () => { this.getPrivileges(); }, - error: (error) => { + error: (error: unknown) => { this.dialogService.error(this.errorHandler.parseError(error)); }, }); diff --git a/src/app/pages/credentials/groups/store/group.effects.ts b/src/app/pages/credentials/groups/store/group.effects.ts index e03b8ce0f38..ffa581c663e 100644 --- a/src/app/pages/credentials/groups/store/group.effects.ts +++ b/src/app/pages/credentials/groups/store/group.effects.ts @@ -6,7 +6,7 @@ import { of } from 'rxjs'; import { catchError, filter, map, switchMap, } from 'rxjs/operators'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { Group } from 'app/interfaces/group.interface'; import { QueryParams } from 'app/interfaces/query-api.interface'; import { @@ -32,7 +32,7 @@ export class GroupEffects { } return this.api.call('group.query', params).pipe( map((groups) => groupsLoaded({ groups })), - catchError((error) => { + catchError((error: unknown) => { console.error(error); // TODO: See if it would make sense to parse middleware error. return of(groupsNotLoaded({ @@ -49,7 +49,7 @@ export class GroupEffects { ofType(groupsLoaded), switchMap(() => { return this.api.subscribe('group.query').pipe( - filter((event) => event.msg === IncomingApiMessageType.Removed), + filter((event) => event.msg === CollectionChangeType.Removed), map((event) => groupRemoved({ id: event.id as number })), ); }), diff --git a/src/app/pages/credentials/users/store/user.effects.ts b/src/app/pages/credentials/users/store/user.effects.ts index 4632f5651ca..769f1623b2f 100644 --- a/src/app/pages/credentials/users/store/user.effects.ts +++ b/src/app/pages/credentials/users/store/user.effects.ts @@ -6,7 +6,7 @@ import { of } from 'rxjs'; import { catchError, filter, map, switchMap, } from 'rxjs/operators'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { QueryParams } from 'app/interfaces/query-api.interface'; import { User } from 'app/interfaces/user.interface'; import { @@ -32,7 +32,7 @@ export class UserEffects { } return this.api.call('user.query', params).pipe( map((users) => usersLoaded({ users })), - catchError((error) => { + catchError((error: unknown) => { console.error(error); // TODO: See if it would make sense to parse middleware error. return of(usersNotLoaded({ @@ -49,7 +49,7 @@ export class UserEffects { ofType(usersLoaded), switchMap(() => { return this.api.subscribe('user.query').pipe( - filter((event) => event.msg === IncomingApiMessageType.Removed), + filter((event) => event.msg === CollectionChangeType.Removed), map((event) => userRemoved({ id: event.id as number })), ); }), diff --git a/src/app/pages/credentials/users/user-api-keys/user-api-keys.component.ts b/src/app/pages/credentials/users/user-api-keys/user-api-keys.component.ts index c7d8245ed6d..5dd63744066 100644 --- a/src/app/pages/credentials/users/user-api-keys/user-api-keys.component.ts +++ b/src/app/pages/credentials/users/user-api-keys/user-api-keys.component.ts @@ -204,7 +204,7 @@ export class UserApiKeysComponent implements OnInit { untilDestroyed(this), ).subscribe({ next: () => this.dataProvider.load(), - error: (error) => { + error: (error: unknown) => { this.errorHandler.showErrorModal(error); this.loader.close(); }, diff --git a/src/app/pages/dashboard/services/dashboard.store.ts b/src/app/pages/dashboard/services/dashboard.store.ts index da0301d0fa8..b83dc5796eb 100644 --- a/src/app/pages/dashboard/services/dashboard.store.ts +++ b/src/app/pages/dashboard/services/dashboard.store.ts @@ -82,7 +82,7 @@ export class DashboardStore extends ComponentStore { groups: this.getDashboardGroups(dashState || getDefaultWidgets(isHaLicensed)), }); }), - catchError((error) => { + catchError((error: unknown) => { this.handleError(error); return EMPTY; }), diff --git a/src/app/pages/dashboard/widgets/network/widget-interface/widget-interface.component.ts b/src/app/pages/dashboard/widgets/network/widget-interface/widget-interface.component.ts index 6dc246b6c73..cbbca1587d5 100644 --- a/src/app/pages/dashboard/widgets/network/widget-interface/widget-interface.component.ts +++ b/src/app/pages/dashboard/widgets/network/widget-interface/widget-interface.component.ts @@ -69,7 +69,7 @@ export class WidgetInterfaceComponent implements WidgetComponent this.resources.networkInterfaces$.pipe( map((interfaces) => mapLoadedValue(interfaces, (nics) => getNetworkInterface(nics, interfaceId))), - catchError((error: Error) => { + catchError((error: unknown) => { return of({ isLoading: false, error } as LoadingState); }), )), diff --git a/src/app/pages/data-protection/cloud-backup/cloud-backup-card/cloud-backup-card.component.ts b/src/app/pages/data-protection/cloud-backup/cloud-backup-card/cloud-backup-card.component.ts index 8d8f3377dae..5a65ac7c9ff 100644 --- a/src/app/pages/data-protection/cloud-backup/cloud-backup-card/cloud-backup-card.component.ts +++ b/src/app/pages/data-protection/cloud-backup/cloud-backup-card/cloud-backup-card.component.ts @@ -219,7 +219,7 @@ export class CloudBackupCardComponent implements OnInit { next: () => { this.getCloudBackups(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/cloud-backup/cloud-backup-details/cloud-backup-snapshots/cloud-backup-snapshots.component.ts b/src/app/pages/data-protection/cloud-backup/cloud-backup-details/cloud-backup-snapshots/cloud-backup-snapshots.component.ts index 2cd4dbc1ff3..d7ff2840878 100644 --- a/src/app/pages/data-protection/cloud-backup/cloud-backup-details/cloud-backup-snapshots/cloud-backup-snapshots.component.ts +++ b/src/app/pages/data-protection/cloud-backup/cloud-backup-details/cloud-backup-snapshots/cloud-backup-snapshots.component.ts @@ -154,7 +154,7 @@ export class CloudBackupSnapshotsComponent implements OnChanges { filter(Boolean), switchMap(() => this.api.job('cloud_backup.delete_snapshot', [this.backup().id, row.id])), tapOnce(() => this.loader.open()), - catchError((error) => { + catchError((error: unknown) => { this.dialog.error(this.errorHandler.parseError(error)); return EMPTY; }), diff --git a/src/app/pages/data-protection/cloud-backup/cloud-backup-list/cloud-backup-list.component.ts b/src/app/pages/data-protection/cloud-backup/cloud-backup-list/cloud-backup-list.component.ts index ee71b8cc447..61ba0febca4 100644 --- a/src/app/pages/data-protection/cloud-backup/cloud-backup-list/cloud-backup-list.component.ts +++ b/src/app/pages/data-protection/cloud-backup/cloud-backup-list/cloud-backup-list.component.ts @@ -235,7 +235,7 @@ export class CloudBackupListComponent implements OnInit { next: () => { this.getCloudBackups(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/cloudsync/cloudsync-form/cloudsync-form.component.ts b/src/app/pages/data-protection/cloudsync/cloudsync-form/cloudsync-form.component.ts index c09f8666b70..8572f406a74 100644 --- a/src/app/pages/data-protection/cloudsync/cloudsync-form/cloudsync-form.component.ts +++ b/src/app/pages/data-protection/cloudsync/cloudsync-form/cloudsync-form.component.ts @@ -25,11 +25,11 @@ import { ExplorerNodeType } from 'app/enums/explorer-type.enum'; import { mntPath } from 'app/enums/mnt-path.enum'; import { Role } from 'app/enums/role.enum'; import { TransferMode, transferModeNames } from 'app/enums/transfer-mode.enum'; +import { extractApiError } from 'app/helpers/api.helper'; import { prepareBwlimit } from 'app/helpers/bwlimit.utils'; import { buildNormalizedFileSize } from 'app/helpers/file-size.utils'; import { mapToOptions } from 'app/helpers/options.helper'; import { helptextCloudSync } from 'app/helptext/data-protection/cloudsync/cloudsync'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { CloudSyncTask, CloudSyncTaskUi, CloudSyncTaskUpdate } from 'app/interfaces/cloud-sync-task.interface'; import { CloudSyncCredential } from 'app/interfaces/cloudsync-credential.interface'; import { CloudSyncProvider } from 'app/interfaces/cloudsync-provider.interface'; @@ -61,6 +61,7 @@ import { CreateStorjBucketDialogComponent } from 'app/pages/data-protection/clou import { CustomTransfersDialogComponent } from 'app/pages/data-protection/cloudsync/custom-transfers-dialog/custom-transfers-dialog.component'; import { TransferModeExplanationComponent } from 'app/pages/data-protection/cloudsync/transfer-mode-explanation/transfer-mode-explanation.component'; import { CloudCredentialService } from 'app/services/cloud-credential.service'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; import { FilesystemService } from 'app/services/filesystem.service'; import { ApiService } from 'app/services/websocket/api.service'; @@ -220,7 +221,8 @@ export class CloudSyncFormComponent implements OnInit { private api: ApiService, protected router: Router, private cdr: ChangeDetectorRef, - private errorHandler: FormErrorHandlerService, + private formErrorHandler: FormErrorHandlerService, + private errorHandler: ErrorHandlerService, private snackbar: SnackbarService, private dialogService: DialogService, protected matDialog: MatDialog, @@ -268,7 +270,7 @@ export class CloudSyncFormComponent implements OnInit { catchError((error: unknown) => { this.isLoading = false; this.cdr.markForCheck(); - this.errorHandler.handleValidationErrors(error, this.form); + this.formErrorHandler.handleValidationErrors(error, this.form); return EMPTY; }), untilDestroyed(this), @@ -433,21 +435,27 @@ export class CloudSyncFormComponent implements OnInit { this.form.controls.bucket_input.disable(); this.cdr.markForCheck(); }, - error: (error: ApiError) => { + error: (error: unknown) => { this.isLoading = false; this.form.controls.bucket.disable(); this.form.controls.bucket_input.enable(); this.dialogService.closeAllDialogs(); + this.cdr.markForCheck(); + const apiError = extractApiError(error); + if (!apiError) { + this.errorHandler.handleError(error); + return; + } + this.dialogService.confirm({ - title: error.extra ? (error.extra as { excerpt: string }).excerpt : `${this.translate.instant('Error: ')}${error.error}`, - message: error.reason, + title: apiError.extra ? (apiError.extra as { excerpt: string }).excerpt : `${this.translate.instant('Error: ')}${apiError.error}`, + message: apiError.reason, hideCheckbox: true, buttonText: this.translate.instant('Fix Credential'), }).pipe(filter(Boolean), untilDestroyed(this)).subscribe(() => { const navigationExtras: NavigationExtras = { state: { editCredential: 'cloudcredentials', id: targetCredentials.id } }; this.router.navigate(['/', 'credentials', 'backup-credentials'], navigationExtras); }); - this.cdr.markForCheck(); }, }); } @@ -753,7 +761,7 @@ export class CloudSyncFormComponent implements OnInit { }, error: (error: unknown) => { this.isLoading = false; - this.errorHandler.handleValidationErrors(error, this.form); + this.formErrorHandler.handleValidationErrors(error, this.form); this.cdr.markForCheck(); }, }); diff --git a/src/app/pages/data-protection/cloudsync/cloudsync-list/cloudsync-list.component.ts b/src/app/pages/data-protection/cloudsync/cloudsync-list/cloudsync-list.component.ts index 43367cfc265..c3fd41c67a8 100644 --- a/src/app/pages/data-protection/cloudsync/cloudsync-list/cloudsync-list.component.ts +++ b/src/app/pages/data-protection/cloudsync/cloudsync-list/cloudsync-list.component.ts @@ -200,7 +200,7 @@ export class CloudSyncListComponent implements OnInit { tapOnce(() => this.snackbar.success( this.translate.instant('Cloud Sync «{name}» has started.', { name: row.description }), )), - catchError((error: Job) => { + catchError((error: unknown) => { this.getCloudSyncTasks(); this.dialogService.error(this.errorHandler.parseError(error)); return EMPTY; @@ -246,7 +246,7 @@ export class CloudSyncListComponent implements OnInit { tapOnce(() => this.snackbar.success( this.translate.instant('Cloud Sync «{name}» has started.', { name: row.description }), )), - catchError((error: Job) => { + catchError((error: unknown) => { this.getCloudSyncTasks(); this.dialogService.error(this.errorHandler.parseError(error)); return EMPTY; @@ -309,7 +309,7 @@ export class CloudSyncListComponent implements OnInit { ); this.getCloudSyncTasks(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/cloudsync/cloudsync-task-card/cloudsync-task-card.component.ts b/src/app/pages/data-protection/cloudsync/cloudsync-task-card/cloudsync-task-card.component.ts index 7efb7629939..72f219e6833 100644 --- a/src/app/pages/data-protection/cloudsync/cloudsync-task-card/cloudsync-task-card.component.ts +++ b/src/app/pages/data-protection/cloudsync/cloudsync-task-card/cloudsync-task-card.component.ts @@ -197,7 +197,7 @@ export class CloudSyncTaskCardComponent implements OnInit { next: () => { this.getCloudSyncTasks(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/cloudsync/cloudsync-wizard/cloudsync-wizard.component.ts b/src/app/pages/data-protection/cloudsync/cloudsync-wizard/cloudsync-wizard.component.ts index 55a9d4f1fc1..e12b407a812 100644 --- a/src/app/pages/data-protection/cloudsync/cloudsync-wizard/cloudsync-wizard.component.ts +++ b/src/app/pages/data-protection/cloudsync/cloudsync-wizard/cloudsync-wizard.component.ts @@ -107,7 +107,7 @@ export class CloudSyncWizardComponent { this.cdr.markForCheck(); }, - error: (err) => { + error: (err: unknown) => { this.isLoading$.next(false); this.dialogService.error(this.errorHandler.parseError(err)); }, diff --git a/src/app/pages/data-protection/cloudsync/cloudsync-wizard/steps/cloudsync-what-and-when/cloudsync-what-and-when.component.spec.ts b/src/app/pages/data-protection/cloudsync/cloudsync-wizard/steps/cloudsync-what-and-when/cloudsync-what-and-when.component.spec.ts index d58d1fbb355..731a5d28ec4 100644 --- a/src/app/pages/data-protection/cloudsync/cloudsync-wizard/steps/cloudsync-what-and-when/cloudsync-what-and-when.component.spec.ts +++ b/src/app/pages/data-protection/cloudsync/cloudsync-wizard/steps/cloudsync-what-and-when/cloudsync-what-and-when.component.spec.ts @@ -9,6 +9,8 @@ import { of } from 'rxjs'; import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils'; import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; import { DialogService } from 'app/modules/dialog/dialog.service'; +import { IxInputHarness } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.harness'; +import { IxSelectHarness } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.harness'; import { IxFormHarness } from 'app/modules/forms/ix-forms/testing/ix-form.harness'; import { ChainedRef } from 'app/modules/slide-ins/chained-component-ref'; import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; @@ -132,4 +134,28 @@ describe('CloudSyncWhatAndWhenComponent', () => { }); expect(chainedRef.swap).toHaveBeenCalledWith(CloudSyncFormComponent, true); }); + + it('checks payload when use invalid s3 credentials', async () => { + const bucketSelect = await loader.getHarness(IxSelectHarness.with({ label: 'Bucket' })); + expect(await bucketSelect.getValue()).toBe(''); + + spectator.component.isCredentialInvalid$.next(true); + spectator.detectChanges(); + + const bucketInput = await loader.getHarness(IxInputHarness.with({ label: 'Bucket' })); + await bucketInput.setValue('selected'); + + expect(spectator.component.getPayload()).toEqual(expect.objectContaining({ + attributes: expect.objectContaining({ + bucket: 'selected', + }), + })); + + await bucketInput.setValue('test-bucket'); + expect(spectator.component.getPayload()).toEqual(expect.objectContaining({ + attributes: expect.objectContaining({ + bucket: 'test-bucket', + }), + })); + }); }); diff --git a/src/app/pages/data-protection/cloudsync/cloudsync-wizard/steps/cloudsync-what-and-when/cloudsync-what-and-when.component.ts b/src/app/pages/data-protection/cloudsync/cloudsync-wizard/steps/cloudsync-what-and-when/cloudsync-what-and-when.component.ts index cfdd871e30a..140baa9ba69 100644 --- a/src/app/pages/data-protection/cloudsync/cloudsync-wizard/steps/cloudsync-what-and-when/cloudsync-what-and-when.component.ts +++ b/src/app/pages/data-protection/cloudsync/cloudsync-wizard/steps/cloudsync-what-and-when/cloudsync-what-and-when.component.ts @@ -12,6 +12,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService, TranslateModule } from '@ngx-translate/core'; import { find, findIndex, isArray } from 'lodash-es'; import { + BehaviorSubject, EMPTY, Observable, catchError, combineLatest, filter, map, merge, of, tap, } from 'rxjs'; @@ -22,10 +23,10 @@ import { ExplorerNodeType } from 'app/enums/explorer-type.enum'; import { mntPath } from 'app/enums/mnt-path.enum'; import { Role } from 'app/enums/role.enum'; import { TransferMode, transferModeNames } from 'app/enums/transfer-mode.enum'; +import { extractApiError } from 'app/helpers/api.helper'; import { prepareBwlimit } from 'app/helpers/bwlimit.utils'; import { mapToOptions } from 'app/helpers/options.helper'; import { helptextCloudSync } from 'app/helptext/data-protection/cloudsync/cloudsync'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { CloudSyncTaskUpdate } from 'app/interfaces/cloud-sync-task.interface'; import { CloudSyncCredential } from 'app/interfaces/cloudsync-credential.interface'; import { CloudSyncProvider } from 'app/interfaces/cloudsync-provider.interface'; @@ -49,6 +50,7 @@ import { CloudSyncFormComponent } from 'app/pages/data-protection/cloudsync/clou import { CreateStorjBucketDialogComponent } from 'app/pages/data-protection/cloudsync/create-storj-bucket-dialog/create-storj-bucket-dialog.component'; import { TransferModeExplanationComponent } from 'app/pages/data-protection/cloudsync/transfer-mode-explanation/transfer-mode-explanation.component'; import { CloudCredentialService } from 'app/services/cloud-credential.service'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; import { FilesystemService } from 'app/services/filesystem.service'; import { ApiService } from 'app/services/websocket/api.service'; @@ -118,6 +120,7 @@ export class CloudSyncWhatAndWhenComponent implements OnInit, OnChanges { bwlimit: [[] as string[]], }); + isCredentialInvalid$ = new BehaviorSubject(false); credentials: CloudSyncCredential[] = []; providers: CloudSyncProvider[] = []; bucketPlaceholder: string = helptextCloudSync.bucket_placeholder; @@ -163,6 +166,7 @@ export class CloudSyncWhatAndWhenComponent implements OnInit, OnChanges { private translate: TranslateService, private filesystemService: FilesystemService, private formErrorHandler: FormErrorHandlerService, + private errorHandler: ErrorHandlerService, private cloudCredentialService: CloudCredentialService, private matDialog: MatDialog, private router: Router, @@ -273,6 +277,8 @@ export class CloudSyncWhatAndWhenComponent implements OnInit, OnChanges { if (formValue[name] !== undefined && formValue[name] !== null && formValue[name] !== '') { if (name === 'task_encryption') { attributes[name] = formValue[name] === '' ? null : formValue[name]; + } else if (name === 'bucket_input') { + attributes['bucket'] = formValue[name]; } else { attributes[name] = formValue[name]; } @@ -438,6 +444,16 @@ export class CloudSyncWhatAndWhenComponent implements OnInit, OnChanges { } }); + this.isCredentialInvalid$.pipe(untilDestroyed(this)).subscribe((value) => { + if (value) { + this.form.controls.bucket_input.enable(); + this.form.controls.bucket.disable(); + } else { + this.form.controls.bucket_input.disable(); + this.form.controls.bucket.enable(); + } + }); + this.form.controls.bucket.valueChanges.pipe( filter((selectedOption) => selectedOption === newOption), untilDestroyed(this), @@ -479,17 +495,21 @@ export class CloudSyncWhatAndWhenComponent implements OnInit, OnChanges { }); } this.bucketOptions$ = of(bucketOptions); - this.form.controls.bucket.enable(); - this.form.controls.bucket_input.disable(); + this.isCredentialInvalid$.next(false); this.cdr.markForCheck(); }, - error: (error: ApiError) => { - this.form.controls.bucket.disable(); - this.form.controls.bucket_input.enable(); + error: (error: unknown) => { + this.isCredentialInvalid$.next(true); this.dialog.closeAllDialogs(); + const apiError = extractApiError(error); + if (!apiError) { + this.errorHandler.handleError(error); + return; + } + this.dialog.confirm({ - title: error.extra ? (error.extra as { excerpt: string }).excerpt : `${this.translate.instant('Error: ')}${error.error}`, - message: error.reason, + title: apiError.extra ? (apiError.extra as { excerpt: string }).excerpt : `${this.translate.instant('Error: ')}${apiError.error}`, + message: apiError.reason, hideCheckbox: true, buttonText: this.translate.instant('Fix Credential'), }).pipe(filter(Boolean), untilDestroyed(this)).subscribe(() => { diff --git a/src/app/pages/data-protection/replication/replication-form/replication-form.component.ts b/src/app/pages/data-protection/replication/replication-form/replication-form.component.ts index 5192188d339..4f686755332 100644 --- a/src/app/pages/data-protection/replication/replication-form/replication-form.component.ts +++ b/src/app/pages/data-protection/replication/replication-form/replication-form.component.ts @@ -14,7 +14,6 @@ import { Role } from 'app/enums/role.enum'; import { SnapshotNamingOption } from 'app/enums/snapshot-naming-option.enum'; import { TransportMode } from 'app/enums/transport-mode.enum'; import { helptextReplicationWizard } from 'app/helptext/data-protection/replication/replication-wizard'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { CountManualSnapshotsParams } from 'app/interfaces/count-manual-snapshots.interface'; import { KeychainSshCredentials } from 'app/interfaces/keychain-credential.interface'; import { ReplicationCreate, ReplicationTask } from 'app/interfaces/replication-task.interface'; @@ -203,7 +202,7 @@ export class ReplicationFormComponent implements OnInit { this.cdr.markForCheck(); this.chainedRef.close({ response, error: null }); }, - error: (error) => { + error: (error: unknown) => { this.isLoading = false; this.cdr.markForCheck(); this.dialog.error(this.errorHandler.parseError(error)); @@ -294,11 +293,12 @@ export class ReplicationFormComponent implements OnInit { this.isLoading = false; this.cdr.markForCheck(); }, - error: (error: ApiError) => { + error: (error: unknown) => { this.isEligibleSnapshotsMessageRed = true; this.eligibleSnapshotsMessage = this.translate.instant('Error counting eligible snapshots.'); - if ('reason' in error) { - this.eligibleSnapshotsMessage = `${this.eligibleSnapshotsMessage} ${error.reason}`; + const firstError = this.errorHandler.getFirstErrorMessage(error); + if (firstError) { + this.eligibleSnapshotsMessage = `${this.eligibleSnapshotsMessage} ${firstError}`; } this.isLoading = false; diff --git a/src/app/pages/data-protection/replication/replication-list/replication-list.component.ts b/src/app/pages/data-protection/replication/replication-list/replication-list.component.ts index 45218675788..3f4d390b382 100644 --- a/src/app/pages/data-protection/replication/replication-list/replication-list.component.ts +++ b/src/app/pages/data-protection/replication/replication-list/replication-list.component.ts @@ -1,5 +1,4 @@ import { AsyncPipe } from '@angular/common'; -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; @@ -277,7 +276,7 @@ export class ReplicationListComponent implements OnInit { next: () => { this.getReplicationTasks(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); @@ -305,12 +304,12 @@ export class ReplicationListComponent implements OnInit { next: (file) => { this.download.downloadBlob(file, fileName); }, - error: (err: HttpErrorResponse) => { - this.dialogService.error(this.errorHandler.parseHttpError(err)); + error: (err: unknown) => { + this.dialogService.error(this.errorHandler.parseError(err)); }, }); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/replication/replication-task-card/replication-task-card.component.ts b/src/app/pages/data-protection/replication/replication-task-card/replication-task-card.component.ts index 618aaba01f3..4d04a19d90c 100644 --- a/src/app/pages/data-protection/replication/replication-task-card/replication-task-card.component.ts +++ b/src/app/pages/data-protection/replication/replication-task-card/replication-task-card.component.ts @@ -1,5 +1,4 @@ import { AsyncPipe } from '@angular/common'; -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatCard } from '@angular/material/card'; @@ -191,7 +190,7 @@ export class ReplicationTaskCardComponent implements OnInit { next: () => { this.getReplicationTasks(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); @@ -267,12 +266,12 @@ export class ReplicationTaskCardComponent implements OnInit { next: (file) => { this.download.downloadBlob(file, `${row.name}_encryption_keys.json`); }, - error: (err: HttpErrorResponse) => { - this.dialogService.error(this.errorHandler.parseHttpError(err)); + error: (err: unknown) => { + this.dialogService.error(this.errorHandler.parseError(err)); }, }); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/replication/replication-wizard/replication-wizard.component.ts b/src/app/pages/data-protection/replication/replication-wizard/replication-wizard.component.ts index ee1735d6d03..6b948b5bf56 100644 --- a/src/app/pages/data-protection/replication/replication-wizard/replication-wizard.component.ts +++ b/src/app/pages/data-protection/replication/replication-wizard/replication-wizard.component.ts @@ -157,7 +157,7 @@ export class ReplicationWizardComponent { switchMap((createdReplication) => { if (values.schedule_method === ScheduleMethod.Once && createdReplication) { return this.runReplicationOnce(createdReplication).pipe( - catchError((err) => { + catchError((err: unknown) => { this.handleError(err); return EMPTY; }), diff --git a/src/app/pages/data-protection/replication/replication-wizard/steps/replication-what-and-where/replication-what-and-where.component.ts b/src/app/pages/data-protection/replication/replication-wizard/steps/replication-what-and-where/replication-what-and-where.component.ts index fcb585f4faa..6760c41f7a7 100644 --- a/src/app/pages/data-protection/replication/replication-wizard/steps/replication-what-and-where/replication-what-and-where.component.ts +++ b/src/app/pages/data-protection/replication/replication-wizard/steps/replication-what-and-where/replication-what-and-where.component.ts @@ -19,7 +19,6 @@ import { Role } from 'app/enums/role.enum'; import { SnapshotNamingOption } from 'app/enums/snapshot-naming-option.enum'; import { TransportMode } from 'app/enums/transport-mode.enum'; import { helptextReplicationWizard } from 'app/helptext/data-protection/replication/replication-wizard'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { CountManualSnapshotsParams, EligibleManualSnapshotsCount } from 'app/interfaces/count-manual-snapshots.interface'; import { KeychainSshCredentials } from 'app/interfaces/keychain-credential.interface'; import { newOption, Option } from 'app/interfaces/option.interface'; @@ -43,6 +42,7 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; import { ReplicationFormComponent } from 'app/pages/data-protection/replication/replication-form/replication-form.component'; import { AuthService } from 'app/services/auth/auth.service'; import { DatasetService } from 'app/services/dataset-service/dataset.service'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; import { KeychainCredentialService } from 'app/services/keychain-credential.service'; import { ReplicationService } from 'app/services/replication.service'; import { ApiService } from 'app/services/websocket/api.service'; @@ -173,6 +173,7 @@ export class ReplicationWhatAndWhereComponent implements OnInit, SummaryProvider private dialogService: DialogService, private api: ApiService, private cdr: ChangeDetectorRef, + private errorHandler: ErrorHandlerService, ) {} ngOnInit(): void { @@ -439,9 +440,12 @@ export class ReplicationWhatAndWhereComponent implements OnInit, SummaryProvider this.snapshotsText = `${this.translate.instant('{count} snapshots found.', { count: snapshotCount.eligible })} ${snapexpl}`; this.cdr.markForCheck(); }, - error: (error: ApiError) => { + error: (error: unknown) => { this.snapshotsText = ''; - this.form.controls.source_datasets.setErrors({ [ixManualValidateError]: { message: error.reason } }); + const errorMessage = this.errorHandler.getFirstErrorMessage(error); + if (errorMessage) { + this.form.controls.source_datasets.setErrors({ [ixManualValidateError]: { message: errorMessage } }); + } this.cdr.markForCheck(); }, }); diff --git a/src/app/pages/data-protection/rsync-task/rsync-task-card/rsync-task-card.component.ts b/src/app/pages/data-protection/rsync-task/rsync-task-card/rsync-task-card.component.ts index 762325c0254..e569e267b8a 100644 --- a/src/app/pages/data-protection/rsync-task/rsync-task-card/rsync-task-card.component.ts +++ b/src/app/pages/data-protection/rsync-task/rsync-task-card/rsync-task-card.component.ts @@ -177,7 +177,7 @@ export class RsyncTaskCardComponent implements OnInit { next: () => { this.getRsyncTasks(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.spec.ts b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.spec.ts index 2820878edbb..c2be0293317 100644 --- a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.spec.ts +++ b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.spec.ts @@ -159,6 +159,7 @@ describe('RsyncTaskFormComponent', () => { recursive: false, remotehost: 'pentagon.gov', remotemodule: 'module', + ssh_credentials: null, schedule: { dom: '*', dow: '*', hour: '2', minute: '0', month: '*', }, @@ -228,6 +229,7 @@ describe('RsyncTaskFormComponent', () => { ...existingTask, path: '/mnt/new', direction: Direction.Push, + ssh_credentials: null, times: false, compress: false, delayupdates: true, diff --git a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts index 89d0f1a6910..400b080fd3d 100644 --- a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts +++ b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts @@ -191,8 +191,8 @@ export class RsyncTaskFormComponent implements OnInit { delete values.remoteport; delete values.remotepath; delete values.validate_rpath; - delete values.ssh_credentials; delete values.ssh_keyscan; + values.ssh_credentials = null; } else { delete values.remotemodule; if (values.sshconnectmode === RsyncSshConnectMode.PrivateKey) { diff --git a/src/app/pages/data-protection/scrub-task/resilver-config/resilver-config.component.ts b/src/app/pages/data-protection/scrub-task/resilver-config/resilver-config.component.ts index 71c8c4ec363..50382da6151 100644 --- a/src/app/pages/data-protection/scrub-task/resilver-config/resilver-config.component.ts +++ b/src/app/pages/data-protection/scrub-task/resilver-config/resilver-config.component.ts @@ -106,7 +106,7 @@ export class ResilverConfigComponent implements OnInit { this.isFormLoading = false; this.cdr.markForCheck(); }, - error: (error) => { + error: (error: unknown) => { this.isFormLoading = false; this.cdr.markForCheck(); this.dialogService.error(this.errorHandler.parseError(error)); diff --git a/src/app/pages/data-protection/scrub-task/scrub-task-card/scrub-task-card.component.ts b/src/app/pages/data-protection/scrub-task/scrub-task-card/scrub-task-card.component.ts index 5c0d747977c..0e8ac31db5c 100644 --- a/src/app/pages/data-protection/scrub-task/scrub-task-card/scrub-task-card.component.ts +++ b/src/app/pages/data-protection/scrub-task/scrub-task-card/scrub-task-card.component.ts @@ -141,7 +141,7 @@ export class ScrubTaskCardComponent implements OnInit { next: () => { this.getScrubTasks(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/smart-task/smart-task-card/smart-task-card.component.ts b/src/app/pages/data-protection/smart-task/smart-task-card/smart-task-card.component.ts index 6c25a112e8f..43a0aabf343 100644 --- a/src/app/pages/data-protection/smart-task/smart-task-card/smart-task-card.component.ts +++ b/src/app/pages/data-protection/smart-task/smart-task-card/smart-task-card.component.ts @@ -160,7 +160,7 @@ export class SmartTaskCardComponent implements OnInit { next: () => { this.getSmartTasks(); }, - error: (error) => this.errorHandler.showErrorModal(error), + error: (error: unknown) => this.errorHandler.showErrorModal(error), }); } diff --git a/src/app/pages/data-protection/smart-task/smart-task-list/smart-task-list.component.ts b/src/app/pages/data-protection/smart-task/smart-task-list/smart-task-list.component.ts index a4cb3aab402..c979b15fad7 100644 --- a/src/app/pages/data-protection/smart-task/smart-task-list/smart-task-list.component.ts +++ b/src/app/pages/data-protection/smart-task/smart-task-list/smart-task-list.component.ts @@ -181,7 +181,7 @@ export class SmartTaskListComponent implements OnInit { next: () => { this.getSmartTasks(); }, - error: (error) => this.errorHandler.showErrorModal(error), + error: (error: unknown) => this.errorHandler.showErrorModal(error), }); } diff --git a/src/app/pages/data-protection/snapshot-task/snapshot-task-card/snapshot-task-card.component.ts b/src/app/pages/data-protection/snapshot-task/snapshot-task-card/snapshot-task-card.component.ts index fd98dfe88e4..7d7698ae06e 100644 --- a/src/app/pages/data-protection/snapshot-task/snapshot-task-card/snapshot-task-card.component.ts +++ b/src/app/pages/data-protection/snapshot-task/snapshot-task-card/snapshot-task-card.component.ts @@ -161,7 +161,7 @@ export class SnapshotTaskCardComponent implements OnInit { next: () => { this.getSnapshotTasks(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/snapshot-task/snapshot-task-list/snapshot-task-list.component.ts b/src/app/pages/data-protection/snapshot-task/snapshot-task-list/snapshot-task-list.component.ts index cf5adb61164..de2ab770e97 100644 --- a/src/app/pages/data-protection/snapshot-task/snapshot-task-list/snapshot-task-list.component.ts +++ b/src/app/pages/data-protection/snapshot-task/snapshot-task-list/snapshot-task-list.component.ts @@ -238,7 +238,7 @@ export class SnapshotTaskListComponent implements OnInit { next: () => { this.getSnapshotTasks(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-form/vmware-snapshot-form.component.ts b/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-form/vmware-snapshot-form.component.ts index 6a9d67e5445..18d0839b8e1 100644 --- a/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-form/vmware-snapshot-form.component.ts +++ b/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-form/vmware-snapshot-form.component.ts @@ -10,8 +10,8 @@ import { Observable, of } from 'rxjs'; import { filter } from 'rxjs/operators'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { Role } from 'app/enums/role.enum'; +import { extractApiError } from 'app/helpers/api.helper'; import { helptextVmwareSnapshot } from 'app/helptext/storage/vmware-snapshot/vmware-snapshot'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { MatchDatastoresWithDatasets, VmwareDatastore, VmwareFilesystem, VmwareSnapshot, VmwareSnapshotUpdate, } from 'app/interfaces/vmware.interface'; @@ -161,10 +161,11 @@ export class VmwareSnapshotFormComponent implements OnInit { ); this.cdr.markForCheck(); }, - error: (error: ApiError) => { + error: (error: unknown) => { this.isLoading = false; this.datastoreOptions$ = of([]); - if (error.reason?.includes('[ETIMEDOUT]')) { + const apiError = extractApiError(error); + if (apiError?.reason?.includes('[ETIMEDOUT]')) { this.dialogService.error({ title: helptextVmwareSnapshot.connect_err_dialog.title, message: helptextVmwareSnapshot.connect_err_dialog.msg, diff --git a/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-list/vmware-snapshot-list.component.ts b/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-list/vmware-snapshot-list.component.ts index bd24c27e2bc..c8bc0f26add 100644 --- a/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-list/vmware-snapshot-list.component.ts +++ b/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-list/vmware-snapshot-list.component.ts @@ -148,7 +148,7 @@ export class VmwareSnapshotListComponent implements OnInit { next: () => { this.getSnapshotsData(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-list/vmware-status-cell/vmware-status-cell.component.ts b/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-list/vmware-status-cell/vmware-status-cell.component.ts index 1aed514e363..4145ae6fa15 100644 --- a/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-list/vmware-status-cell/vmware-status-cell.component.ts +++ b/src/app/pages/data-protection/vmware-snapshot/vmware-snapshot-list/vmware-status-cell/vmware-status-cell.component.ts @@ -30,7 +30,8 @@ export class VmwareStatusCellComponent { get tooltip(): string { if (this.state().state === VmwareSnapshotStatus.Error) { - return this.state().error ? this.translate.instant(this.state().error) : this.translate.instant('Error'); + const error = this.state().error; + return error ? this.translate.instant(error) : this.translate.instant('Error'); } return this.state().state === VmwareSnapshotStatus.Pending ? this.translate.instant('Pending') diff --git a/src/app/pages/datasets/components/dataset-form/dataset-form.component.ts b/src/app/pages/datasets/components/dataset-form/dataset-form.component.ts index 12b2c071dad..c80099ab05e 100644 --- a/src/app/pages/datasets/components/dataset-form/dataset-form.component.ts +++ b/src/app/pages/datasets/components/dataset-form/dataset-form.component.ts @@ -172,7 +172,7 @@ export class DatasetFormComponent implements OnInit, AfterViewInit { this.isLoading = false; this.cdr.markForCheck(); }, - error: (error) => { + error: (error: unknown) => { this.isLoading = false; this.cdr.markForCheck(); this.dialog.error(this.errorHandler.parseError(error)); @@ -200,7 +200,7 @@ export class DatasetFormComponent implements OnInit, AfterViewInit { this.isLoading = false; this.cdr.markForCheck(); }, - error: (error) => { + error: (error: unknown) => { this.isLoading = false; this.cdr.markForCheck(); this.dialog.error(this.errorHandler.parseError(error)); @@ -260,7 +260,7 @@ export class DatasetFormComponent implements OnInit, AfterViewInit { ); } }, - error: (error) => { + error: (error: unknown) => { this.isLoading = false; this.cdr.markForCheck(); this.dialog.error(this.errorHandler.parseError(error)); @@ -307,7 +307,7 @@ export class DatasetFormComponent implements OnInit, AfterViewInit { path: `${mntPath}/${dataset.id}`, }]).pipe( switchMap(() => of(dataset)), - catchError((error) => this.rollBack(dataset, error)), + catchError((error: unknown) => this.rollBack(dataset, error)), ); } @@ -320,7 +320,7 @@ export class DatasetFormComponent implements OnInit, AfterViewInit { path: `${mntPath}/${dataset.id}`, }]).pipe( switchMap(() => of(dataset)), - catchError((error) => this.rollBack(dataset, error)), + catchError((error: unknown) => this.rollBack(dataset, error)), ); } diff --git a/src/app/pages/datasets/components/dataset-management/dataset-management.component.spec.ts b/src/app/pages/datasets/components/dataset-management/dataset-management.component.spec.ts index 433554e3dd4..562bd395bb0 100644 --- a/src/app/pages/datasets/components/dataset-management/dataset-management.component.spec.ts +++ b/src/app/pages/datasets/components/dataset-management/dataset-management.component.spec.ts @@ -5,6 +5,7 @@ import { MockComponent } from 'ng-mocks'; import { BehaviorSubject, of } from 'rxjs'; import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils'; import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; +import { ErrorResponse } from 'app/interfaces/api-message.interface'; import { SystemDatasetConfig } from 'app/interfaces/system-dataset-config.interface'; import { EmptyComponent } from 'app/modules/empty/empty.component'; import { SearchInput1Component } from 'app/modules/forms/search-input1/search-input1.component'; @@ -78,7 +79,14 @@ describe('DatasetsManagementComponent', () => { }); it('should display error when datasets loading fails', () => { - error$.next({ reason: 'Network Error' }); + error$.next({ + jsonrpc: '2.0', + error: { + data: { + reason: 'Network Error', + }, + }, + } as ErrorResponse); datasets$.next([]); spectator.detectChanges(); diff --git a/src/app/pages/datasets/components/dataset-management/dataset-management.component.ts b/src/app/pages/datasets/components/dataset-management/dataset-management.component.ts index ea6270b9819..312413db0c6 100644 --- a/src/app/pages/datasets/components/dataset-management/dataset-management.component.ts +++ b/src/app/pages/datasets/components/dataset-management/dataset-management.component.ts @@ -40,6 +40,7 @@ import { import { DetailsHeightDirective } from 'app/directives/details-height/details-height.directive'; import { EmptyType } from 'app/enums/empty-type.enum'; import { Role } from 'app/enums/role.enum'; +import { extractApiError } from 'app/helpers/api.helper'; import { WINDOW } from 'app/helpers/window.helper'; import { ApiError } from 'app/interfaces/api-error.interface'; import { DatasetDetails } from 'app/interfaces/dataset.interface'; @@ -119,12 +120,13 @@ export class DatasetsManagementComponent implements OnInit, AfterViewInit, OnDes emptyConf = computed(() => { const error = this.error(); - if (error?.reason) { + const apiError = extractApiError(error); + if (apiError?.reason) { return { type: EmptyType.Errors, large: true, title: this.translate.instant('Failed to load datasets'), - message: this.translate.instant(error.reason || error?.error?.toString()), + message: this.translate.instant(apiError.reason || apiError?.error?.toString()), button: { label: this.translate.instant('Retry'), action: () => this.datasetStore.loadDatasets(), diff --git a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-edit-form/dataset-quota-edit-form.component.ts b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-edit-form/dataset-quota-edit-form.component.ts index 10b15c69bf9..e60f7c584ce 100644 --- a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-edit-form/dataset-quota-edit-form.component.ts +++ b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-edit-form/dataset-quota-edit-form.component.ts @@ -16,9 +16,7 @@ import { DatasetQuotaType } from 'app/enums/dataset.enum'; import { Role } from 'app/enums/role.enum'; import { helptextGlobal } from 'app/helptext/global-helptext'; import { helpTextQuotas } from 'app/helptext/storage/volumes/datasets/dataset-quotas'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { DatasetQuota, SetDatasetQuota } from 'app/interfaces/dataset-quota.interface'; -import { Job } from 'app/interfaces/job.interface'; import { QueryFilter, QueryParams } from 'app/interfaces/query-api.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; @@ -163,7 +161,7 @@ export class DatasetQuotaEditFormComponent implements OnInit { }); this.cdr.markForCheck(); }), - catchError((error: ApiError | Job) => { + catchError((error: unknown) => { this.isFormLoading = false; this.errorHandler.handleValidationErrors(error, this.form); this.cdr.markForCheck(); diff --git a/src/app/pages/datasets/components/dataset-quotas/dataset-quotas-list/dataset-quotas-list.component.ts b/src/app/pages/datasets/components/dataset-quotas/dataset-quotas-list/dataset-quotas-list.component.ts index 352e7459e80..f32d168dc15 100644 --- a/src/app/pages/datasets/components/dataset-quotas/dataset-quotas-list/dataset-quotas-list.component.ts +++ b/src/app/pages/datasets/components/dataset-quotas/dataset-quotas-list/dataset-quotas-list.component.ts @@ -19,10 +19,8 @@ import { DatasetQuotaType } from 'app/enums/dataset.enum'; import { EmptyType } from 'app/enums/empty-type.enum'; import { Role } from 'app/enums/role.enum'; import { helpTextQuotas } from 'app/helptext/storage/volumes/datasets/dataset-quotas'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { DatasetQuota, SetDatasetQuota } from 'app/interfaces/dataset-quota.interface'; import { ConfirmOptions } from 'app/interfaces/dialog.interface'; -import { Job } from 'app/interfaces/job.interface'; import { QueryFilter, QueryParams } from 'app/interfaces/query-api.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { EmptyService } from 'app/modules/empty/empty.service'; @@ -304,7 +302,7 @@ export class DatasetQuotasListComponent implements OnInit { tap(() => { this.getQuotas(); }), - catchError((error: ApiError | Job) => { + catchError((error: unknown) => { this.handleError(error); return EMPTY; }), @@ -368,7 +366,7 @@ export class DatasetQuotasListComponent implements OnInit { tap(() => { this.getQuotas(); }), - catchError((error: ApiError | Job) => { + catchError((error: unknown) => { this.handleError(error); return EMPTY; }), diff --git a/src/app/pages/datasets/components/delete-dataset-dialog/delete-dataset-dialog.component.spec.ts b/src/app/pages/datasets/components/delete-dataset-dialog/delete-dataset-dialog.component.spec.ts index 684aa8718af..0daf514af5c 100644 --- a/src/app/pages/datasets/components/delete-dataset-dialog/delete-dataset-dialog.component.spec.ts +++ b/src/app/pages/datasets/components/delete-dataset-dialog/delete-dataset-dialog.component.spec.ts @@ -139,7 +139,12 @@ describe('DeleteDatasetDialogComponent', () => { it('asks to force delete a dataset if it cannot be deleted because device is busy', async () => { const websocketMock = spectator.inject(MockApiService); jest.spyOn(websocketMock, 'call').mockImplementationOnce(() => throwError(() => ({ - reason: 'Device busy', + jsonrpc: '2.0', + error: { + data: { + reason: 'Device busy', + }, + }, }))); await confirmAndDelete(); diff --git a/src/app/pages/datasets/components/delete-dataset-dialog/delete-dataset-dialog.component.ts b/src/app/pages/datasets/components/delete-dataset-dialog/delete-dataset-dialog.component.ts index df97a7b5cca..49eb10b218d 100644 --- a/src/app/pages/datasets/components/delete-dataset-dialog/delete-dataset-dialog.component.ts +++ b/src/app/pages/datasets/components/delete-dataset-dialog/delete-dataset-dialog.component.ts @@ -16,7 +16,7 @@ import { catchError, switchMap, tap } from 'rxjs/operators'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { DatasetType } from 'app/enums/dataset.enum'; import { Role } from 'app/enums/role.enum'; -import { ApiError } from 'app/interfaces/api-error.interface'; +import { extractApiError } from 'app/helpers/api.helper'; import { DatasetAttachment } from 'app/interfaces/pool-attachment.interface'; import { Process } from 'app/interfaces/process.interface'; import { VolumesListDataset } from 'app/interfaces/volumes-list-pool.interface'; @@ -90,8 +90,9 @@ export class DeleteDatasetDialogComponent implements OnInit { onDelete(): void { this.deleteDataset().pipe( - catchError((error: ApiError) => { - if (error.reason.includes('Device busy')) { + catchError((error: unknown) => { + const apiError = extractApiError(error); + if (apiError?.reason?.includes('Device busy')) { return this.askToForceDelete(); } diff --git a/src/app/pages/datasets/modules/encryption/components/encryption-options-dialog/encryption-options-dialog.component.ts b/src/app/pages/datasets/modules/encryption/components/encryption-options-dialog/encryption-options-dialog.component.ts index 0e576a06454..4d868c90c44 100644 --- a/src/app/pages/datasets/modules/encryption/components/encryption-options-dialog/encryption-options-dialog.component.ts +++ b/src/app/pages/datasets/modules/encryption/components/encryption-options-dialog/encryption-options-dialog.component.ts @@ -17,7 +17,6 @@ import { EncryptionKeyFormat } from 'app/enums/encryption-key-format.enum'; import { Role } from 'app/enums/role.enum'; import { combineLatestIsAny } from 'app/helpers/operators/combine-latest-is-any.helper'; import { helptextDatasetForm } from 'app/helptext/storage/volumes/datasets/dataset-form'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { DatasetChangeKeyParams } from 'app/interfaces/dataset-change-key.interface'; import { Dataset } from 'app/interfaces/dataset.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; @@ -164,7 +163,7 @@ export class EncryptionOptionsDialogComponent implements OnInit { this.showSuccessMessage(); this.dialogRef.close(true); }, - error: (error: ApiError) => { + error: (error: unknown) => { this.formErrorHandler.handleValidationErrors(error, this.form); }, }); @@ -195,7 +194,7 @@ export class EncryptionOptionsDialogComponent implements OnInit { this.showSuccessMessage(); this.dialogRef.close(true); }, - error: (error: ApiError) => { + error: (error: unknown) => { this.formErrorHandler.handleValidationErrors(error, this.form); }, }); diff --git a/src/app/pages/datasets/modules/encryption/components/export-dataset-key-dialog/export-dataset-key-dialog.component.ts b/src/app/pages/datasets/modules/encryption/components/export-dataset-key-dialog/export-dataset-key-dialog.component.ts index c2c131b4290..9fc7206b633 100644 --- a/src/app/pages/datasets/modules/encryption/components/export-dataset-key-dialog/export-dataset-key-dialog.component.ts +++ b/src/app/pages/datasets/modules/encryption/components/export-dataset-key-dialog/export-dataset-key-dialog.component.ts @@ -1,4 +1,3 @@ -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnInit, } from '@angular/core'; @@ -10,9 +9,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateModule } from '@ngx-translate/core'; import { switchMap } from 'rxjs/operators'; import { JobState } from 'app/enums/job-state.enum'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { Dataset } from 'app/interfaces/dataset.interface'; -import { Job } from 'app/interfaces/job.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { AppLoaderService } from 'app/modules/loader/app-loader.service'; @@ -71,7 +68,7 @@ export class ExportDatasetKeyDialogComponent implements OnInit { next: () => { this.dialogRef.close(); }, - error: (error: ApiError | HttpErrorResponse) => { + error: (error: unknown) => { this.dialogService.error(this.errorHandler.parseError(error)); }, }); @@ -90,7 +87,7 @@ export class ExportDatasetKeyDialogComponent implements OnInit { this.key = job.result; this.cdr.markForCheck(); }, - error: (error: Job | ApiError) => { + error: (error: unknown) => { this.dialogService.error(this.errorHandler.parseError(error)); }, }); diff --git a/src/app/pages/datasets/modules/permissions/components/view-trivial-permissions/view-trivial-permissions.component.html b/src/app/pages/datasets/modules/permissions/components/view-trivial-permissions/view-trivial-permissions.component.html index a84f9bf4c41..e4283bbb67e 100644 --- a/src/app/pages/datasets/modules/permissions/components/view-trivial-permissions/view-trivial-permissions.component.html +++ b/src/app/pages/datasets/modules/permissions/components/view-trivial-permissions/view-trivial-permissions.component.html @@ -1,6 +1,6 @@
    {{ 'Unix Permissions' | translate }}
    -@for (item of permissionItems; track item) { +@for (item of permissionItems(); track item) { diff --git a/src/app/pages/datasets/modules/permissions/components/view-trivial-permissions/view-trivial-permissions.component.ts b/src/app/pages/datasets/modules/permissions/components/view-trivial-permissions/view-trivial-permissions.component.ts index 34e134c7c2c..7a2127733c0 100644 --- a/src/app/pages/datasets/modules/permissions/components/view-trivial-permissions/view-trivial-permissions.component.ts +++ b/src/app/pages/datasets/modules/permissions/components/view-trivial-permissions/view-trivial-permissions.component.ts @@ -1,4 +1,6 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, Component, computed, input, +} from '@angular/core'; import { TranslateService, TranslateModule } from '@ngx-translate/core'; import { parseApiMode } from 'app/helpers/mode.helper'; import { FileSystemStat } from 'app/interfaces/filesystem-stat.interface'; @@ -20,12 +22,11 @@ import { imports: [PermissionsItemComponent, TranslateModule], }) export class ViewTrivialPermissionsComponent { - @Input() - set stat(stat: FileSystemStat) { - this.permissionItems = this.statToPermissionItems(stat); - } + readonly stat = input.required(); - permissionItems: PermissionItem[]; + readonly permissionItems = computed(() => { + return this.statToPermissionItems(this.stat()); + }); constructor( private translate: TranslateService, diff --git a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts index d269bfe1f8d..8e45f69cdc8 100644 --- a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts +++ b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts @@ -18,9 +18,7 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r import { AclType } from 'app/enums/acl-type.enum'; import { Role } from 'app/enums/role.enum'; import { helptextPermissions } from 'app/helptext/storage/volumes/datasets/dataset-permissions'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { FilesystemSetPermParams } from 'app/interfaces/filesystem-stat.interface'; -import { Job } from 'app/interfaces/job.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; @@ -28,6 +26,7 @@ import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-ch import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; import { IxPermissionsComponent } from 'app/modules/forms/ix-forms/components/ix-permissions/ix-permissions.component'; +import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; import { FakeProgressBarComponent } from 'app/modules/loader/components/fake-progress-bar/fake-progress-bar.component'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; @@ -105,14 +104,13 @@ export class DatasetTrivialPermissionsComponent implements OnInit { readonly isRecursive$ = this.form.select((values) => values.recursive); - private oldDatasetMode: string; - constructor( private formBuilder: FormBuilder, private router: Router, private activatedRoute: ActivatedRoute, private api: ApiService, private errorHandler: ErrorHandlerService, + private formErrorHandler: FormErrorHandlerService, private storageService: StorageService, private translate: TranslateService, private dialog: DialogService, @@ -163,8 +161,8 @@ export class DatasetTrivialPermissionsComponent implements OnInit { this.snackbar.success(this.translate.instant('Permissions saved.')); this.router.navigate(['/datasets', this.datasetId]); }, - error: (error: ApiError | Job) => { - this.dialog.error(this.errorHandler.parseError(error)); + error: (error: unknown) => { + this.formErrorHandler.handleValidationErrors(error, this.form); }, }); } diff --git a/src/app/pages/datasets/modules/snapshots/snapshot-batch-delete-dialog/snapshot-batch-delete-dialog.component.ts b/src/app/pages/datasets/modules/snapshots/snapshot-batch-delete-dialog/snapshot-batch-delete-dialog.component.ts index a1e930ce419..b4f2e4060a2 100644 --- a/src/app/pages/datasets/modules/snapshots/snapshot-batch-delete-dialog/snapshot-batch-delete-dialog.component.ts +++ b/src/app/pages/datasets/modules/snapshots/snapshot-batch-delete-dialog/snapshot-batch-delete-dialog.component.ts @@ -14,7 +14,6 @@ import { TranslateModule } from '@ngx-translate/core'; import { filter, map } from 'rxjs/operators'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { Role } from 'app/enums/role.enum'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { CoreBulkQuery, CoreBulkResponse } from 'app/interfaces/core-bulk.interface'; import { Job } from 'app/interfaces/job.interface'; import { ZfsSnapshot } from 'app/interfaces/zfs-snapshot.interface'; @@ -116,7 +115,7 @@ export class SnapshotBatchDeleteDialogComponent implements OnInit { this.isJobCompleted = true; this.cdr.markForCheck(); }, - error: (error: ApiError | Job) => { + error: (error: unknown) => { this.dialogService.error(this.errorHandler.parseError(error)); }, }); diff --git a/src/app/pages/datasets/modules/snapshots/store/snapshot.effects.ts b/src/app/pages/datasets/modules/snapshots/store/snapshot.effects.ts index 4f39dc2c647..0f7cece4819 100644 --- a/src/app/pages/datasets/modules/snapshots/store/snapshot.effects.ts +++ b/src/app/pages/datasets/modules/snapshots/store/snapshot.effects.ts @@ -6,7 +6,7 @@ import { EMPTY, of } from 'rxjs'; import { catchError, filter, map, switchMap, } from 'rxjs/operators'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { QueryFilters } from 'app/interfaces/query-api.interface'; import { ZfsSnapshot } from 'app/interfaces/zfs-snapshot.interface'; import { snapshotExcludeBootQueryFilter } from 'app/pages/datasets/modules/snapshots/constants/snapshot-exclude-boot.constant'; @@ -34,7 +34,7 @@ export class SnapshotEffects { }, ]).pipe( map((snapshots) => snapshotsLoaded({ snapshots })), - catchError((error) => { + catchError((error: unknown) => { console.error(error); // TODO: See if it would make sense to parse middleware error. return of(snapshotsNotLoaded({ @@ -49,12 +49,12 @@ export class SnapshotEffects { ofType(snapshotsLoaded), switchMap(() => { return this.api.subscribe('zfs.snapshot.query').pipe( - filter((event) => event.msg !== IncomingApiMessageType.Removed), + filter((event) => event.msg !== CollectionChangeType.Removed), switchMap((event) => { switch (event.msg) { - case IncomingApiMessageType.Added: + case CollectionChangeType.Added: return of(snapshotAdded({ snapshot: event.fields })); - case IncomingApiMessageType.Changed: + case CollectionChangeType.Changed: return of(snapshotChanged({ snapshot: event.fields })); default: return EMPTY; @@ -68,7 +68,7 @@ export class SnapshotEffects { ofType(snapshotsLoaded), switchMap(() => { return this.api.subscribe('zfs.snapshot.query').pipe( - filter((event) => event.msg === IncomingApiMessageType.Removed), + filter((event) => event.msg === CollectionChangeType.Removed), map((event) => snapshotRemoved({ id: event.id.toString() })), ); }), diff --git a/src/app/pages/datasets/store/dataset-store.service.ts b/src/app/pages/datasets/store/dataset-store.service.ts index b5d2ff0c396..1ae78aa0279 100644 --- a/src/app/pages/datasets/store/dataset-store.service.ts +++ b/src/app/pages/datasets/store/dataset-store.service.ts @@ -4,14 +4,13 @@ import { EMPTY, Observable } from 'rxjs'; import { catchError, switchMap, tap, } from 'rxjs/operators'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { DatasetDetails } from 'app/interfaces/dataset.interface'; import { getTreeBranchToNode } from 'app/pages/datasets/utils/get-tree-branch-to-node.utils'; import { ApiService } from 'app/services/websocket/api.service'; export interface DatasetTreeState { isLoading: boolean; - error: ApiError | null; + error: unknown; datasets: DatasetDetails[]; selectedDatasetId: string | null; } @@ -71,7 +70,7 @@ export class DatasetTreeStore extends ComponentStore { datasets, }); }), - catchError((error: ApiError) => { + catchError((error: unknown) => { this.patchState({ isLoading: false, error, diff --git a/src/app/pages/jobs/job-logs-row/job-logs-row.component.spec.ts b/src/app/pages/jobs/job-logs-row/job-logs-row.component.spec.ts index 135ad4cf151..bfaa89ff046 100644 --- a/src/app/pages/jobs/job-logs-row/job-logs-row.component.spec.ts +++ b/src/app/pages/jobs/job-logs-row/job-logs-row.component.spec.ts @@ -1,6 +1,6 @@ import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; import { JobState } from 'app/enums/job-state.enum'; -import { ResponseErrorType } from 'app/enums/response-error-type.enum'; +import { JobExceptionType } from 'app/enums/response-error-type.enum'; import { Job } from 'app/interfaces/job.interface'; import { CopyButtonComponent } from 'app/modules/buttons/copy-button/copy-button.component'; import { JobLogsRowComponent } from 'app/pages/jobs/job-logs-row/job-logs-row.component'; @@ -13,7 +13,7 @@ const fakeJob: Job = { error: '[EFAULT] Transferred: \t 0 / 0 Byte, -, 0 Byte/s, ETA', exc_info: { extra: null, - type: 'CallError' as ResponseErrorType, + type: 'CallError' as JobExceptionType, }, exception: 'Traceback (most recent call last):\n File "/usr/lib/python3/dist-packages/middlewared/job.py", line 423', id: 446, diff --git a/src/app/pages/network/components/ipmi-card/ipmi-events-dialog/ipmi-events-dialog.component.ts b/src/app/pages/network/components/ipmi-card/ipmi-events-dialog/ipmi-events-dialog.component.ts index 1dbb620f8b6..5832f122cc7 100644 --- a/src/app/pages/network/components/ipmi-card/ipmi-events-dialog/ipmi-events-dialog.component.ts +++ b/src/app/pages/network/components/ipmi-card/ipmi-events-dialog/ipmi-events-dialog.component.ts @@ -78,7 +78,7 @@ export class IpmiEventsDialogComponent implements OnInit { this.isLoading = false; this.cdr.markForCheck(); }, - error: (error) => { + error: (error: unknown) => { this.dialogService.error(this.errorHandler.parseError(error)); }, }); @@ -107,7 +107,7 @@ export class IpmiEventsDialogComponent implements OnInit { this.isLoading = false; this.cdr.markForCheck(); }, - error: (error) => { + error: (error: unknown) => { this.dialogService.error(this.errorHandler.parseError(error)); this.isLoading = false; this.cdr.markForCheck(); diff --git a/src/app/pages/network/stores/interfaces.store.ts b/src/app/pages/network/stores/interfaces.store.ts index 0b908bb1b26..8be5d12a5cd 100644 --- a/src/app/pages/network/stores/interfaces.store.ts +++ b/src/app/pages/network/stores/interfaces.store.ts @@ -33,7 +33,7 @@ export class InterfacesStore extends ComponentStore { return this.api.call('interface.query').pipe( tap({ next: (interfaces) => this.patchState({ interfaces }), - error: (error) => this.dialogService.error(this.errorHandler.parseError(error)), + error: (error: unknown) => this.dialogService.error(this.errorHandler.parseError(error)), complete: () => this.patchState({ isLoading: false }), }), ); diff --git a/src/app/pages/reports-dashboard/components/exporters/reporting-exporters-form/reporting-exporters-form.component.ts b/src/app/pages/reports-dashboard/components/exporters/reporting-exporters-form/reporting-exporters-form.component.ts index ec6db5ca502..a3a0d4c31f3 100644 --- a/src/app/pages/reports-dashboard/components/exporters/reporting-exporters-form/reporting-exporters-form.component.ts +++ b/src/app/pages/reports-dashboard/components/exporters/reporting-exporters-form/reporting-exporters-form.component.ts @@ -137,7 +137,7 @@ export class ReportingExportersFormComponent implements OnInit { this.isLoadingSchemas = false; this.cdr.markForCheck(); }, - error: (error) => { + error: (error: unknown) => { this.dialogService.error(this.errorHandler.parseError(error)); this.isLoading = false; this.isLoadingSchemas = false; diff --git a/src/app/pages/reports-dashboard/components/exporters/reporting-exporters-list/reporting-exporters-list.component.ts b/src/app/pages/reports-dashboard/components/exporters/reporting-exporters-list/reporting-exporters-list.component.ts index 7bbec4be1ac..1db0510fd4f 100644 --- a/src/app/pages/reports-dashboard/components/exporters/reporting-exporters-list/reporting-exporters-list.component.ts +++ b/src/app/pages/reports-dashboard/components/exporters/reporting-exporters-list/reporting-exporters-list.component.ts @@ -100,7 +100,7 @@ export class ReportingExporterListComponent implements OnInit { untilDestroyed(this), ).subscribe({ complete: () => this.appLoader.close(), - error: (error) => this.errorCaught(error), + error: (error: unknown) => this.errorCaught(error), }); }, }), @@ -230,7 +230,7 @@ export class ReportingExporterListComponent implements OnInit { } }, complete: () => this.appLoader.close(), - error: (error) => this.errorCaught(error), + error: (error: unknown) => this.errorCaught(error), }); } diff --git a/src/app/pages/reports-dashboard/components/report/report.component.ts b/src/app/pages/reports-dashboard/components/report/report.component.ts index c4d4543511c..c0e09022ec2 100644 --- a/src/app/pages/reports-dashboard/components/report/report.component.ts +++ b/src/app/pages/reports-dashboard/components/report/report.component.ts @@ -28,7 +28,7 @@ import { oneDayMillis, oneHourMillis } from 'app/constants/time.constant'; import { toggleMenuDuration } from 'app/constants/toggle-menu-duration'; import { EmptyType } from 'app/enums/empty-type.enum'; import { ReportingGraphName } from 'app/enums/reporting.enum'; -import { ApiError } from 'app/interfaces/api-error.interface'; +import { extractApiError } from 'app/helpers/api.helper'; import { ReportingData, ReportingDatabaseError } from 'app/interfaces/reporting.interface'; import { IxSimpleChanges } from 'app/interfaces/simple-changes.interface'; import { FormatDateTimePipe } from 'app/modules/dates/pipes/format-date-time/format-datetime.pipe'; @@ -487,19 +487,20 @@ export class ReportComponent implements OnInit, OnChanges { this.data = formatData(cloneDeep(event)); this.cdr.markForCheck(); }, - error: (err: ApiError) => { + error: (err: unknown) => { this.handleError(err); this.cdr.markForCheck(); }, }); } - handleError(err: ApiError): void { - if (err?.error === (ReportingDatabaseError.FailedExport as number)) { + handleError(err: unknown): void { + const apiError = extractApiError(err); + if (apiError?.error === (ReportingDatabaseError.FailedExport as number)) { this.report().errorConf = { type: EmptyType.Errors, title: this.translate.instant('Error getting chart data'), - message: err.reason, + message: apiError.reason, }; } } diff --git a/src/app/pages/services/components/service-smb/service-smb.component.html b/src/app/pages/services/components/service-smb/service-smb.component.html index 6626c9eafce..f599a3726d9 100644 --- a/src/app/pages/services/components/service-smb/service-smb.component.html +++ b/src/app/pages/services/components/service-smb/service-smb.component.html @@ -58,12 +58,11 @@ [required]="true" > - + { workgroup: 'WORKGROUP', description: 'TrueNAS Server', unixcharset: 'UTF-8', - loglevel: 'MINIMUM', + debug: true, syslog: false, aapl_extensions: false, localmaster: true, @@ -55,7 +55,6 @@ describe('ServiceSmbComponent', () => { admin_group: null, next_rid: 0, multichannel: false, - netbiosname_local: 'truenas', encryption: SmbEncryption.Negotiate, } as SmbConfig), mockCall('smb.unixcharset_choices', { @@ -133,13 +132,13 @@ describe('ServiceSmbComponent', () => { 'File Mask': '', 'Guest Account': '', 'Local Master': true, - 'Log Level': 'Minimum', 'NTLMv1 Auth': false, 'NetBIOS Alias': [], 'NetBIOS Name': 'truenas', 'Transport Encryption Behavior': 'Negotiate – only encrypt transport if explicitly requested by the SMB client', Multichannel: false, 'UNIX Charset': 'UTF-8', + 'Use Debug': true, 'Use Syslog Only': false, Workgroup: 'WORKGROUP', }); @@ -175,7 +174,7 @@ describe('ServiceSmbComponent', () => { guest: 'nobody', dirmask: '', filemask: '', - loglevel: 'MINIMUM', + debug: true, localmaster: true, syslog: false, multichannel: false, @@ -191,8 +190,8 @@ describe('ServiceSmbComponent', () => { const form = await loader.getHarness(IxFormHarness); await form.fillForm({ 'UNIX Charset': 'UTF-16', - 'Log Level': 'Full', 'Use Syslog Only': true, + 'Use Debug': true, 'Local Master': false, 'Enable Apple SMB2/3 Protocol Extensions': true, 'Administrators Group': 'test-group', @@ -221,7 +220,7 @@ describe('ServiceSmbComponent', () => { guest: 'nobody', dirmask: '0777', filemask: '0666', - loglevel: 'FULL', + debug: true, localmaster: false, syslog: true, multichannel: false, diff --git a/src/app/pages/services/components/service-smb/service-smb.component.ts b/src/app/pages/services/components/service-smb/service-smb.component.ts index 7264ee5f79e..8354fb6cd87 100644 --- a/src/app/pages/services/components/service-smb/service-smb.component.ts +++ b/src/app/pages/services/components/service-smb/service-smb.component.ts @@ -10,7 +10,6 @@ import { TranslateService, TranslateModule } from '@ngx-translate/core'; import { of, Subscription } from 'rxjs'; import { map } from 'rxjs/operators'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; -import { LogLevel } from 'app/enums/log-level.enum'; import { Role } from 'app/enums/role.enum'; import { SmbEncryption, smbEncryptionLabels } from 'app/enums/smb-encryption.enum'; import { choicesToOptions } from 'app/helpers/operators/options.operators'; @@ -81,7 +80,7 @@ export class ServiceSmbComponent implements OnInit { enable_smb1: [false, []], ntlmv1_auth: [false, []], unixcharset: ['', []], - loglevel: [LogLevel.None, []], + debug: [false, []], syslog: [false, []], localmaster: [false, []], guest: ['nobody', []], @@ -104,7 +103,7 @@ export class ServiceSmbComponent implements OnInit { enable_smb1: helptextServiceSmb.cifs_srv_enable_smb1_tooltip, ntlmv1_auth: helptextServiceSmb.cifs_srv_ntlmv1_auth_tooltip, unixcharset: helptextServiceSmb.cifs_srv_unixcharset_tooltip, - loglevel: helptextServiceSmb.cifs_srv_loglevel_tooltip, + debug: helptextServiceSmb.cifs_srv_debug_tooltip, syslog: helptextServiceSmb.cifs_srv_syslog_tooltip, localmaster: helptextServiceSmb.cifs_srv_localmaster_tooltip, guest: helptextServiceSmb.cifs_srv_guest_tooltip, @@ -116,14 +115,6 @@ export class ServiceSmbComponent implements OnInit { multichannel: helptextServiceSmb.cifs_srv_multichannel_tooltip, }; - readonly logLevelOptions$ = of([ - { label: this.translate.instant('None'), value: LogLevel.None }, - { label: this.translate.instant('Minimum'), value: LogLevel.Minimum }, - { label: this.translate.instant('Normal'), value: LogLevel.Normal }, - { label: this.translate.instant('Full'), value: LogLevel.Full }, - { label: this.translate.instant('Debug'), value: LogLevel.Debug }, - ]); - readonly unixCharsetOptions$ = this.api.call('smb.unixcharset_choices').pipe(choicesToOptions()); readonly guestAccountOptions$ = this.api.call('user.query').pipe( map((users) => users.map((user) => ({ label: user.username, value: user.username }))), diff --git a/src/app/pages/services/components/service-state-column/service-state-column.component.ts b/src/app/pages/services/components/service-state-column/service-state-column.component.ts index 49228bb3736..6068cc25e8d 100644 --- a/src/app/pages/services/components/service-state-column/service-state-column.component.ts +++ b/src/app/pages/services/components/service-state-column/service-state-column.component.ts @@ -102,7 +102,7 @@ export class ServiceStateColumnComponent extends ColumnComponent { untilDestroyed(this), ).subscribe({ next: () => this.snackbar.success(this.translate.instant('Service stopped')), - error: (error) => { + error: (error: unknown) => { this.errorHandler.showErrorModal(error); toggle.checked = true; }, @@ -115,7 +115,7 @@ export class ServiceStateColumnComponent extends ColumnComponent { untilDestroyed(this), ).subscribe({ next: () => this.snackbar.success(this.translate.instant('Service started')), - error: (error) => { + error: (error: unknown) => { this.errorHandler.showErrorModal(error); toggle.checked = false; }, diff --git a/src/app/pages/services/components/service-ups/service-ups.component.ts b/src/app/pages/services/components/service-ups/service-ups.component.ts index 0ddd31666e7..61606567eb7 100644 --- a/src/app/pages/services/components/service-ups/service-ups.component.ts +++ b/src/app/pages/services/components/service-ups/service-ups.component.ts @@ -189,7 +189,7 @@ export class ServiceUpsComponent implements OnInit { this.isFormLoading = false; this.cdr.markForCheck(); }, - error: (error) => { + error: (error: unknown) => { this.isFormLoading = false; this.dialogService.error(this.errorHandler.parseError(error)); this.cdr.markForCheck(); diff --git a/src/app/pages/services/services.component.ts b/src/app/pages/services/services.component.ts index 634c882e55f..b6be57e066b 100644 --- a/src/app/pages/services/services.component.ts +++ b/src/app/pages/services/services.component.ts @@ -199,7 +199,7 @@ export class ServicesComponent implements OnInit { .pipe( this.loader.withLoader(), take(1), - catchError((error) => { + catchError((error: unknown) => { this.errorHandler.showErrorModal(error); this.store$.dispatch(serviceChanged({ service })); return of(EMPTY); diff --git a/src/app/pages/services/services.elements.ts b/src/app/pages/services/services.elements.ts index 013d061bc0a..721a0cf75b8 100644 --- a/src/app/pages/services/services.elements.ts +++ b/src/app/pages/services/services.elements.ts @@ -10,37 +10,43 @@ export const servicesElements = { manualRenderElements: { smb: { hierarchy: [T('SMB')], - synonyms: [T('Samba')], + synonyms: [T('Samba'), T('SMB Service')], anchor: 'service-smb', }, ftp: { hierarchy: [T('FTP')], + synonyms: [T('FTP Service')], anchor: 'service-ftp', }, iscsi: { hierarchy: [T('iSCSI')], + synonyms: [T('iSCSI Service')], anchor: 'service-iscsi', }, nfs: { hierarchy: [T('NFS')], + synonyms: [T('NFS Service')], anchor: 'service-nfs', }, snmp: { hierarchy: [T('SNMP')], + synonyms: [T('SNMP Service')], anchor: 'service-snmp', }, ssh: { hierarchy: [T('SSH')], + synonyms: [T('SSH Service')], anchor: 'service-ssh', }, ups: { hierarchy: [T('UPS')], + synonyms: [T('UPS Service')], anchor: 'service-ups', }, smart: { hierarchy: [T('S.M.A.R.T.')], + synonyms: [T('Smart Service'), T('Smart')], anchor: 'service-smart', - synonyms: [T('Smart')], }, }, } satisfies UiSearchableElement; diff --git a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.spec.ts b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.spec.ts index 01ff0fe329b..53553377afe 100644 --- a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.spec.ts +++ b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.spec.ts @@ -41,7 +41,7 @@ describe('IscsiCardComponent', () => { id: 6, name: 'grow', alias: 'kokok', - mode: IscsiTargetMode.Iscsi, + mode: IscsiTargetMode.Both, auth_networks: [], groups: [ { @@ -109,8 +109,8 @@ describe('IscsiCardComponent', () => { it('should show table rows', async () => { const expectedRows = [ - ['Target Name', 'Target Alias', ''], - ['grow', 'kokok', ''], + ['Target Name', 'Target Alias', 'Mode', ''], + ['grow', 'kokok', 'Both', ''], ]; const cells = await table.getCellTexts(); @@ -118,7 +118,7 @@ describe('IscsiCardComponent', () => { }); it('shows form to edit an existing iSCSI Share when Edit button is pressed', async () => { - const editButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'edit' }), 1, 2); + const editButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'edit' }), 1, 3); await editButton.click(); expect(spectator.inject(SlideInService).open).toHaveBeenCalledWith(TargetFormComponent, { @@ -128,7 +128,7 @@ describe('IscsiCardComponent', () => { }); it('shows confirmation to delete iSCSI Share when Delete button is pressed', async () => { - const deleteIcon = await table.getHarnessInCell(IxIconHarness.with({ name: 'mdi-delete' }), 1, 2); + const deleteIcon = await table.getHarnessInCell(IxIconHarness.with({ name: 'mdi-delete' }), 1, 3); await deleteIcon.click(); expect(spectator.inject(DialogService).confirm).toHaveBeenCalled(); diff --git a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts index c2d4f515700..9ba2e53c2c8 100644 --- a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts +++ b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts @@ -1,6 +1,7 @@ import { AsyncPipe } from '@angular/common'; import { - ChangeDetectionStrategy, Component, OnInit, + ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, OnInit, + signal, } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { MatCard } from '@angular/material/card'; @@ -9,9 +10,10 @@ import { RouterLink } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { Store } from '@ngrx/store'; import { TranslateService, TranslateModule } from '@ngx-translate/core'; -import { filter, switchMap } from 'rxjs'; +import { filter, switchMap, tap } from 'rxjs'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { UiSearchDirective } from 'app/directives/ui-search.directive'; +import { IscsiTargetMode, iscsiTargetModeNames } from 'app/enums/iscsi.enum'; import { Role } from 'app/enums/role.enum'; import { ServiceName } from 'app/enums/service-name.enum'; import { IscsiTarget } from 'app/interfaces/iscsi.interface'; @@ -76,6 +78,8 @@ export class IscsiCardComponent implements OnInit { Role.SharingWrite, ]; + targets = signal(null); + protected readonly searchableElements = iscsiCardElements; dataProvider: AsyncDataProvider; @@ -89,6 +93,14 @@ export class IscsiCardComponent implements OnInit { title: this.translate.instant('Target Alias'), propertyName: 'alias', }), + textColumn({ + title: this.translate.instant('Mode'), + propertyName: 'mode', + hidden: true, + getValue: (row) => (iscsiTargetModeNames.has(row.mode) + ? this.translate.instant(iscsiTargetModeNames.get(row.mode)) + : row.mode || '-'), + }), actionsColumn({ actions: [ { @@ -117,10 +129,33 @@ export class IscsiCardComponent implements OnInit { private dialogService: DialogService, protected emptyService: EmptyService, private store$: Store, - ) {} + private cdr: ChangeDetectorRef, + ) { + effect(() => { + if (this.targets()?.some((target) => target.mode !== IscsiTargetMode.Iscsi)) { + this.columns = this.columns.map((column) => { + if (column.propertyName === 'mode') { + return { + ...column, + hidden: false, + }; + } + + return column; + }); + this.cdr.detectChanges(); + this.cdr.markForCheck(); + } + }); + } ngOnInit(): void { - const iscsiShares$ = this.api.call('iscsi.target.query').pipe(untilDestroyed(this)); + const iscsiShares$ = this.api.call('iscsi.target.query').pipe( + tap((targets) => { + this.targets.set(targets); + }), + untilDestroyed(this), + ); this.dataProvider = new AsyncDataProvider(iscsiShares$); this.setDefaultSort(); this.dataProvider.load(); @@ -152,7 +187,7 @@ export class IscsiCardComponent implements OnInit { next: () => { this.dataProvider.load(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts index b78227123be..426fa530b1e 100644 --- a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts +++ b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts @@ -143,7 +143,7 @@ export class NfsCardComponent implements OnInit { next: () => { this.dataProvider.load(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts index a47a26e9069..77aa1987695 100644 --- a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts +++ b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts @@ -176,7 +176,7 @@ export class SmbCardComponent implements OnInit { next: () => { this.dataProvider.load(); }, - error: (err) => { + error: (err: unknown) => { this.dialogService.error(this.errorHandler.parseError(err)); }, }); diff --git a/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.html b/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.html new file mode 100644 index 00000000000..8b535194f38 --- /dev/null +++ b/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.html @@ -0,0 +1,34 @@ + + + + +
    + + + + + + + +
    +
    +
    diff --git a/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.scss b/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.scss new file mode 100644 index 00000000000..fb470d95dc5 --- /dev/null +++ b/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.scss @@ -0,0 +1,4 @@ +:host { + box-sizing: border-box; + display: block; +} diff --git a/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.spec.ts b/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.spec.ts new file mode 100644 index 00000000000..9946ea503c9 --- /dev/null +++ b/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.spec.ts @@ -0,0 +1,151 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatCardModule } from '@angular/material/card'; +import { Spectator, createComponentFactory, mockProvider } from '@ngneat/spectator/jest'; +import { mockApi, mockCall } from 'app/core/testing/utils/mock-api.utils'; +import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; +import { FibreChannelPort } from 'app/interfaces/fibre-channel.interface'; +import { IscsiTarget } from 'app/interfaces/iscsi.interface'; +import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; +import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; +import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { IxSelectHarness } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.harness'; +import { IxFormHarness } from 'app/modules/forms/ix-forms/testing/ix-form.harness'; +import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; +import { SLIDE_IN_DATA } from 'app/modules/slide-ins/slide-in.token'; +import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; +import { FibreChannelPortsFormComponent } from 'app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component'; +import { ApiService } from 'app/services/websocket/api.service'; + +describe('FibreChannelPortsFormComponent', () => { + let spectator: Spectator; + let loader: HarnessLoader; + const mockFibreChannel = { + id: 1, + port: 'fc0', + target: { + id: 1, + }, + } as FibreChannelPort; + + const createComponent = createComponentFactory({ + component: FibreChannelPortsFormComponent, + imports: [ + ReactiveFormsModule, + IxSelectComponent, + IxInputComponent, + IxFieldsetComponent, + FormActionsComponent, + MatButtonModule, + MatCardModule, + ], + providers: [ + mockAuth(), + mockProvider(SnackbarService), + mockApi([ + mockCall('fcport.create'), + mockCall('fcport.update'), + mockCall('iscsi.target.query', [{ id: 1, name: 'target1' }, { id: 2, name: 'target2' }] as IscsiTarget[]), + ]), + mockProvider(SlideInRef), + { provide: SLIDE_IN_DATA, useValue: null }, + ], + }); + + describe('creating new fibre channel port', () => { + beforeEach(() => { + spectator = createComponent({ + providers: [ + { provide: SLIDE_IN_DATA, useValue: null }, + ], + }); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + }); + + it('shows correct title when creating new fibre channel port', () => { + const title = spectator.query('ix-modal-header'); + expect(title).toHaveText('Add Fibre Channel Port'); + }); + + it('shows form values when creating new fibre channel port', async () => { + const form = await loader.getHarness(IxFormHarness); + + expect(await form.getValues()).toEqual({ + Port: '', + Target: '', + }); + }); + + it('creates new fibre channel port when form is submitted', async () => { + const form = await loader.getHarness(IxFormHarness); + await form.fillForm({ + Port: 'fc0', + Target: 'target1', + }); + + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + await saveButton.click(); + + expect(spectator.inject(ApiService).call).toHaveBeenCalledWith('fcport.create', [{ + port: 'fc0', + target_id: 1, + }]); + expect(spectator.inject(SnackbarService).success).toHaveBeenCalled(); + expect(spectator.inject(SlideInRef).close).toHaveBeenCalled(); + }); + }); + + describe('editing existing fibre channel port', () => { + beforeEach(() => { + spectator = createComponent({ + providers: [ + { provide: SLIDE_IN_DATA, useValue: mockFibreChannel }, + ], + }); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + }); + + it('shows form values when editing existing fibre channel port', async () => { + const form = await loader.getHarness(IxFormHarness); + + expect(await form.getValues()).toEqual({ + Port: 'fc0', + Target: 'target1', + }); + }); + + it('shows correct title when editing existing fibre channel port', () => { + const title = spectator.query('ix-modal-header'); + expect(title).toHaveText('Edit Fibre Channel Port'); + }); + + it('saves updated fibre channel port when form is submitted', async () => { + const form = await loader.getHarness(IxFormHarness); + await form.fillForm({ + Port: 'fc0', + Target: 'target2', + }); + + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + await saveButton.click(); + + expect(spectator.inject(ApiService).call).toHaveBeenCalledWith('fcport.update', [1, { + port: 'fc0', + target_id: 2, + }]); + expect(spectator.inject(SnackbarService).success).toHaveBeenCalled(); + expect(spectator.inject(SlideInRef).close).toHaveBeenCalled(); + }); + + it('loads and shows target options in select', async () => { + const select = await loader.getHarness(IxSelectHarness.with({ label: 'Target' })); + const options = await select.getOptionLabels(); + + expect(options).toEqual(['target1', 'target2', 'Create New']); + }); + }); +}); diff --git a/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.ts b/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.ts new file mode 100644 index 00000000000..76e0ad95e75 --- /dev/null +++ b/src/app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component.ts @@ -0,0 +1,124 @@ +import { + Component, ChangeDetectionStrategy, computed, inject, signal, OnInit, +} from '@angular/core'; +import { ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { MatCard, MatCardContent } from '@angular/material/card'; +import { FormBuilder } from '@ngneat/reactive-forms'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; +import { Role } from 'app/enums/role.enum'; +import { idNameArrayToOptions } from 'app/helpers/operators/options.operators'; +import { FibreChannelPort, FibreChannelPortUpdate } from 'app/interfaces/fibre-channel.interface'; +import { newOption } from 'app/interfaces/option.interface'; +import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; +import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; +import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; +import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; +import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; +import { SLIDE_IN_DATA } from 'app/modules/slide-ins/slide-in.token'; +import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; +import { IscsiService } from 'app/services/iscsi.service'; +import { ApiService } from 'app/services/websocket/api.service'; + +@UntilDestroy() +@Component({ + standalone: true, + selector: 'ix-fibre-channel-ports-form', + templateUrl: './fibre-channel-ports-form.component.html', + styleUrls: ['./fibre-channel-ports-form.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormActionsComponent, + IxFieldsetComponent, + IxInputComponent, + IxSelectComponent, + MatButton, + MatCard, + MatCardContent, + ModalHeaderComponent, + ReactiveFormsModule, + TranslateModule, + RequiresRolesDirective, + ], +}) +export class FibreChannelPortsFormComponent implements OnInit { + protected requiredRoles: Role[] = [Role.FullAdmin]; + protected fibreChannel = signal(inject(SLIDE_IN_DATA)); + protected isNew = computed(() => !this.fibreChannel()); + protected isLoading = signal(false); + + protected readonly targetOptions$ = this.iscsiService.getTargets() + .pipe( + idNameArrayToOptions(), + switchMap((options) => of([ + ...options, + { label: this.translate.instant('Create New'), value: newOption }, + ])), + ); + + protected title = computed(() => { + return this.isNew() + ? this.translate.instant('Add Fibre Channel Port') + : this.translate.instant('Edit Fibre Channel Port'); + }); + + protected form = this.fb.group({ + port: ['', [Validators.required]], + target_id: [null as number, [Validators.required]], + }); + + constructor( + private api: ApiService, + private snackbar: SnackbarService, + private translate: TranslateService, + private fb: FormBuilder, + private iscsiService: IscsiService, + private errorHandler: FormErrorHandlerService, + private slideInRef: SlideInRef, + ) {} + + ngOnInit(): void { + if (!this.isNew()) { + this.setFibreChannelForEdit(); + } + } + + setFibreChannelForEdit(): void { + const values = this.fibreChannel(); + this.form.patchValue({ + port: values.port, + target_id: values.target.id, + }); + } + + onSubmit(): void { + const payload = { ...this.form.value } as FibreChannelPortUpdate; + this.isLoading.set(true); + + const request$ = this.isNew() + ? this.api.call('fcport.create', [payload]) + : this.api.call('fcport.update', [this.fibreChannel().id, payload]); + + request$.pipe(untilDestroyed(this)).subscribe({ + next: (response) => { + this.snackbar.success( + this.isNew() + ? this.translate.instant('Fibre Channel Port has been created') + : this.translate.instant('Fibre Channel Port has been updated'), + ); + this.isLoading.set(false); + this.slideInRef.close(response); + }, + error: (error: unknown) => { + this.isLoading.set(false); + this.errorHandler.handleValidationErrors(error, this.form); + }, + }); + } +} diff --git a/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.html b/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.html index c4779e53191..093ba4a3ce3 100644 --- a/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.html +++ b/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.html @@ -1,9 +1,45 @@ - +

    {{ 'Fibre Channel Ports' | translate }}

    + +
    + + + +
    + + + + + +
    diff --git a/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.spec.ts b/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.spec.ts new file mode 100644 index 00000000000..c81e2b24098 --- /dev/null +++ b/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.spec.ts @@ -0,0 +1,174 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { of } from 'rxjs'; +import { mockApi, mockCall } from 'app/core/testing/utils/mock-api.utils'; +import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; +import { FibreChannelPort, FibreChannelStatus } from 'app/interfaces/fibre-channel.interface'; +import { DialogService } from 'app/modules/dialog/dialog.service'; +import { EmptyService } from 'app/modules/empty/empty.service'; +import { IxIconHarness } from 'app/modules/ix-icon/ix-icon.harness'; +import { IxTableHarness } from 'app/modules/ix-table/components/ix-table/ix-table.harness'; +import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; +import { FibreChannelPortsFormComponent } from 'app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component'; +import { SlideInService } from 'app/services/slide-in.service'; +import { ApiService } from 'app/services/websocket/api.service'; +import { selectIsHaLicensed } from 'app/store/ha-info/ha-info.selectors'; +import { FibreChannelPortsComponent } from './fibre-channel-ports.component'; + +describe('FibreChannelPortsComponent', () => { + let spectator: Spectator; + let loader: HarnessLoader; + let table: IxTableHarness; + let store$: MockStore; + + const mockFibreChannelPort = { + id: 1, + port: 'fc1', + wwpn: '10:00:00:00:00:00:00:01', + wwpn_b: '10:00:00:00:00:00:00:02', + target: { + id: 1, + iscsi_target_name: 'target1', + }, + } as FibreChannelPort; + + const mockFcStatus = [ + { + port: 'fc0', + A: { + port_type: 'INITIATOR', + port_state: 'ONLINE', + speed: '16Gb', + physical: true, + wwpn: '10:00:00:00:00:00:00:01', + }, + B: { + port_type: 'INITIATOR', + port_state: 'OFFLINE', + speed: '16Gb', + physical: true, + wwpn: '10:00:00:00:00:00:00:02', + }, + }, + { + port: 'fc1', + A: { + port_type: 'TARGET', + port_state: 'ONLINE', + speed: '32Gb', + physical: true, + wwpn: '20:00:00:00:00:00:00:01', + }, + B: { + port_type: 'TARGET', + port_state: 'ONLINE', + speed: '32Gb', + physical: true, + wwpn: '20:00:00:00:00:00:00:02', + }, + }, + ]; + + const createComponent = createComponentFactory({ + component: FibreChannelPortsComponent, + providers: [ + mockAuth(), + mockApi([ + mockCall('fcport.query', [mockFibreChannelPort]), + mockCall('fcport.delete'), + mockCall('fcport.status', mockFcStatus as FibreChannelStatus[]), + ]), + mockProvider(SlideInService, { + open: jest.fn(() => { + return { slideInClosed$: of(true) }; + }), + onClose$: of(), + }), + mockProvider(SlideInRef, { + slideInClosed$: of(true), + }), + mockProvider(DialogService, { + confirm: jest.fn(() => of(true)), + }), + mockProvider(EmptyService), + provideMockStore({ + selectors: [{ + selector: selectIsHaLicensed, + value: true, + }], + }), + ], + }); + + beforeEach(async () => { + spectator = createComponent(); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + table = await loader.getHarness(IxTableHarness); + store$ = spectator.inject(MockStore); + }); + + it('shows accurate page title', () => { + const title = spectator.query('h3'); + expect(title).toHaveText('Fibre Channel Ports'); + }); + + it('should show correct table rows', async () => { + const expectedRows = [ + ['Port', 'Target', 'WWPN', 'WWPN (B)', 'State', ''], + [ + 'fc1', + 'target1', + '10:00:00:00:00:00:00:01', + '10:00:00:00:00:00:00:02', + 'A:ONLINE B:ONLINE', + '', + ], + ]; + + const cells = await table.getCellTexts(); + expect(cells).toEqual(expectedRows); + }); + + it('opens fibre channel port form when "Edit" button is pressed', async () => { + const editButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'edit' }), 1, 5); + await editButton.click(); + + expect(spectator.inject(SlideInService).open) + .toHaveBeenCalledWith(FibreChannelPortsFormComponent, { data: mockFibreChannelPort }); + }); + + it('opens confirmation dialog when Delete is clicked and deletes the port when confirmed', async () => { + const deleteButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'mdi-delete' }), 1, 5); + await deleteButton.click(); + + expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith({ + title: 'Delete Fibre Channel Port', + message: 'Are you sure you want to delete Fibre Channel Port fc1?', + buttonText: 'Delete', + cancelText: 'Cancel', + }); + expect(spectator.inject(ApiService).call).toHaveBeenCalledWith('fcport.delete', [1]); + }); + + it('should load data on init', () => { + const apiService = spectator.inject(ApiService); + expect(apiService.call).toHaveBeenCalledWith('fcport.query'); + }); + + it('should show/hide WWPN (B) column based on HA status', async () => { + store$.overrideSelector(selectIsHaLicensed, false); + store$.refreshState(); + spectator.detectChanges(); + spectator.detectComponentChanges(); + + const headers = await table.getHeaderTexts(); + expect(headers).not.toContain('WWPN (B)'); + }); + + it('should show correct state from status data', async () => { + const cells = await table.getCellTexts(); + expect(cells[1][4]).toBe('A:ONLINE B:ONLINE'); + }); +}); diff --git a/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.ts b/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.ts index 955147b7d8f..b23f89ea20a 100644 --- a/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.ts +++ b/src/app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.component.ts @@ -1,11 +1,46 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, Component, signal, OnInit, + computed, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { MatToolbarRow } from '@angular/material/toolbar'; -import { TranslateModule } from '@ngx-translate/core'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { Store } from '@ngrx/store'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + filter, switchMap, tap, +} from 'rxjs/operators'; +import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { UiSearchDirective } from 'app/directives/ui-search.directive'; +import { Role } from 'app/enums/role.enum'; +import { FibreChannelPort } from 'app/interfaces/fibre-channel.interface'; +import { DialogService } from 'app/modules/dialog/dialog.service'; +import { EmptyService } from 'app/modules/empty/empty.service'; +import { SearchInput1Component } from 'app/modules/forms/search-input1/search-input1.component'; +import { iconMarker } from 'app/modules/ix-icon/icon-marker.util'; +import { AsyncDataProvider } from 'app/modules/ix-table/classes/async-data-provider/async-data-provider'; +import { IxTableComponent } from 'app/modules/ix-table/components/ix-table/ix-table.component'; +import { actionsColumn } from 'app/modules/ix-table/components/ix-table-body/cells/ix-cell-actions/ix-cell-actions.component'; +import { textColumn } from 'app/modules/ix-table/components/ix-table-body/cells/ix-cell-text/ix-cell-text.component'; +import { IxTableBodyComponent } from 'app/modules/ix-table/components/ix-table-body/ix-table-body.component'; +import { IxTableHeadComponent } from 'app/modules/ix-table/components/ix-table-head/ix-table-head.component'; +import { IxTablePagerComponent } from 'app/modules/ix-table/components/ix-table-pager/ix-table-pager.component'; +import { IxTableEmptyDirective } from 'app/modules/ix-table/directives/ix-table-empty.directive'; +import { SortDirection } from 'app/modules/ix-table/enums/sort-direction.enum'; +import { createTable } from 'app/modules/ix-table/utils'; import { FakeProgressBarComponent } from 'app/modules/loader/components/fake-progress-bar/fake-progress-bar.component'; +import { TestDirective } from 'app/modules/test-id/test.directive'; import { fibreChannelPortsElements } from 'app/pages/sharing/iscsi/fibre-channel-ports/fibre-channel-ports.elements'; +import { FibreChannelPortsFormComponent } from 'app/pages/sharing/iscsi/fibre-channel-ports-form/fibre-channel-ports-form.component'; +import { SlideInService } from 'app/services/slide-in.service'; +import { ApiService } from 'app/services/websocket/api.service'; +import { AppState } from 'app/store'; +import { selectIsHaLicensed } from 'app/store/ha-info/ha-info.selectors'; +@UntilDestroy() @Component({ selector: 'ix-fibre-channel-ports', templateUrl: './fibre-channel-ports.component.html', @@ -14,13 +49,136 @@ import { fibreChannelPortsElements } from 'app/pages/sharing/iscsi/fibre-channel standalone: true, imports: [ FakeProgressBarComponent, + IxTableBodyComponent, + IxTableComponent, + IxTableEmptyDirective, + IxTableHeadComponent, + IxTablePagerComponent, + MatButton, MatCard, + MatCardContent, MatToolbarRow, + RequiresRolesDirective, + SearchInput1Component, + TestDirective, TranslateModule, - MatCardContent, UiSearchDirective, + AsyncPipe, ], }) -export class FibreChannelPortsComponent { +export class FibreChannelPortsComponent implements OnInit { protected readonly searchableElements = fibreChannelPortsElements; + protected requiredRoles: Role[] = [Role.FullAdmin]; + protected searchQuery = signal(''); + protected dataProvider: AsyncDataProvider; + protected isHa = toSignal(this.store$.select(selectIsHaLicensed)); + protected status = toSignal(this.api.call('fcport.status')); + + protected columns = computed(() => { + return createTable([ + textColumn({ + title: this.translate.instant('Port'), + propertyName: 'port', + getValue: (row) => row.port, + sortBy: (row) => row.port, + }), + textColumn({ + title: this.translate.instant('Target'), + propertyName: 'target', + getValue: (row) => { + return row.target.iscsi_target_name; + }, + }), + textColumn({ + title: this.translate.instant('WWPN'), + propertyName: 'wwpn', + }), + textColumn({ + title: this.translate.instant('WWPN (B)'), + propertyName: 'wwpn_b', + hidden: !this.isHa(), + }), + textColumn({ + title: this.translate.instant('State'), + getValue: (row) => { + const status = this.status()?.find((item) => item.port === row.port); + return `A:${status?.A?.port_state} B:${status?.B?.port_state}`; + }, + }), + actionsColumn({ + actions: [ + { + iconName: iconMarker('edit'), + tooltip: this.translate.instant('Edit'), + onClick: (row) => this.doEdit(row), + }, { + iconName: iconMarker('mdi-delete'), + tooltip: this.translate.instant('Delete'), + onClick: (row) => this.doDelete(row), + }, + ], + }), + ], { + uniqueRowTag: (row: FibreChannelPort) => 'fibre-channel-port-' + row.port, + ariaLabels: (row) => [row.port, this.translate.instant('Fibre Channel Port')], + }); + }); + + constructor( + private api: ApiService, + private translate: TranslateService, + private slideIn: SlideInService, + private store$: Store, + private dialog: DialogService, + protected emptyService: EmptyService, + ) { } + + ngOnInit(): void { + this.dataProvider = new AsyncDataProvider(this.api.call('fcport.query')); + this.setDefaultSort(); + this.dataProvider.load(); + } + + doAdd(): void { + this.slideIn.open(FibreChannelPortsFormComponent).slideInClosed$.pipe( + filter(Boolean), + tap(() => this.dataProvider.load()), + untilDestroyed(this), + ).subscribe(); + } + + doEdit(port: FibreChannelPort): void { + this.slideIn.open(FibreChannelPortsFormComponent, { data: port }).slideInClosed$.pipe( + filter(Boolean), + tap(() => this.dataProvider.load()), + untilDestroyed(this), + ).subscribe(); + } + + doDelete(port: FibreChannelPort): void { + this.dialog.confirm({ + title: this.translate.instant('Delete Fibre Channel Port'), + message: this.translate.instant('Are you sure you want to delete Fibre Channel Port {port}?', { port: port.port }), + buttonText: this.translate.instant('Delete'), + cancelText: this.translate.instant('Cancel'), + }).pipe( + filter(Boolean), + switchMap(() => this.api.call('fcport.delete', [port.id])), + untilDestroyed(this), + ).subscribe(() => { + this.dataProvider.load(); + }); + } + + onSearch(query: string): void { + this.searchQuery.set(query); + } + + setDefaultSort(): void { + this.dataProvider.setSorting({ + active: 0, + direction: SortDirection.Asc, + propertyName: 'port', + }); + } } diff --git a/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.html b/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.html index d43a6ff2e48..a68c6557187 100644 --- a/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.html +++ b/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.html @@ -5,16 +5,17 @@ - - {{ dataProvider.expandedRow?.name }} - +
    + + {{ 'Details for' | translate }} + {{ dataProvider.expandedRow?.name }} + - @if (dataProvider.expandedRow; as target) {
    } -
    +
    @if (dataProvider.expandedRow) { diff --git a/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.scss b/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.scss index f25a72cb30b..ad983bc412b 100644 --- a/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.scss +++ b/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.scss @@ -1,15 +1,10 @@ -:host { - &::ng-deep { - .details-container .header { - margin-top: 13px; - } - } -} - -.detail-actions { +.detail-header { + align-items: center; display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: flex-end; - width: 100%; + justify-content: space-between; + + .detail-actions { + display: flex; + gap: 8px; + } } diff --git a/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.ts b/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.ts index 470607f9cb7..903779fd65d 100644 --- a/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.ts +++ b/src/app/pages/sharing/iscsi/target/all-targets/all-targets.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, OnInit, + signal, } from '@angular/core'; import { MatButton } from '@angular/material/button'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; @@ -41,6 +42,7 @@ import { ApiService } from 'app/services/websocket/api.service'; }) export class AllTargetsComponent implements OnInit { protected dataProvider: AsyncDataProvider; + targets = signal(null); readonly requiredRoles = [ Role.SharingIscsiTargetWrite, @@ -61,6 +63,8 @@ export class AllTargetsComponent implements OnInit { ngOnInit(): void { const targets$ = this.iscsiService.getTargets().pipe( tap((targets) => { + this.targets.set(targets); + const firstTarget = targets[targets.length - 1]; if (!this.dataProvider.expandedRow && firstTarget) { this.dataProvider.expandedRow = firstTarget; diff --git a/src/app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component.html b/src/app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component.html new file mode 100644 index 00000000000..f50a98fff9e --- /dev/null +++ b/src/app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component.html @@ -0,0 +1,12 @@ + + +

    + {{ 'Fibre Channel Port' | translate }} +

    +
    + +

    {{ 'Name' | translate }}: {{ port().id }}

    +

    {{ 'Controller A WWPN' | translate }}: {{ port().wwpn }}

    +

    {{ 'Controller B WWPN' | translate }}: {{ port().wwpn_b }}

    +
    +
    diff --git a/src/app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component.spec.ts b/src/app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component.spec.ts new file mode 100644 index 00000000000..911d15e7403 --- /dev/null +++ b/src/app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component.spec.ts @@ -0,0 +1,37 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { TranslateModule } from '@ngx-translate/core'; +import { FibreChannelPort } from 'app/interfaces/fibre-channel.interface'; +import { FibreChannelPortCardComponent } from './fibre-channel-port-card.component'; + +describe('FibreChannelPortCardComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: FibreChannelPortCardComponent, + imports: [TranslateModule.forRoot()], + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + port: { + id: 'Port-1', + wwpn: '10:00:00:00:c9:20:00:00', + wwpn_b: '10:00:00:00:c9:20:00:01', + } as unknown as FibreChannelPort, + }, + }); + }); + + it('renders Fibre Channel Port title', () => { + const title = spectator.query('h3[mat-card-title]'); + expect(title).toHaveText('Fibre Channel Port'); + }); + + it('displays port details correctly', () => { + const content = spectator.queryAll('mat-card-content p'); + expect(content).toHaveLength(3); + expect(content[0]).toHaveText('Name: Port-1'); + expect(content[1]).toHaveText('Controller A WWPN: 10:00:00:00:c9:20:00:00'); + expect(content[2]).toHaveText('Controller B WWPN: 10:00:00:00:c9:20:00:01'); + }); +}); diff --git a/src/app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component.ts b/src/app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component.ts new file mode 100644 index 00000000000..4de0eab6e3a --- /dev/null +++ b/src/app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { + MatCard, MatCardContent, MatCardHeader, MatCardTitle, +} from '@angular/material/card'; +import { TranslateModule } from '@ngx-translate/core'; +import { FibreChannelPort } from 'app/interfaces/fibre-channel.interface'; + +@Component({ + selector: 'ix-fibre-channel-port-card', + styleUrls: ['./fibre-channel-port-card.component.scss'], + templateUrl: './fibre-channel-port-card.component.html', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatCard, + MatCardHeader, + MatCardTitle, + TranslateModule, + MatCardContent, + ], +}) +export class FibreChannelPortCardComponent { + readonly port = input.required(); +} diff --git a/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.html b/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.html index d18c377234c..41a6ac7a120 100644 --- a/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.html +++ b/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.html @@ -6,6 +6,12 @@ } } + @if (hasFibreCards()) { + @if (targetPort()) { + + } + } +
    diff --git a/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.spec.ts b/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.spec.ts new file mode 100644 index 00000000000..6638dc89916 --- /dev/null +++ b/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.spec.ts @@ -0,0 +1,91 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { MockComponents } from 'ng-mocks'; +import { of } from 'rxjs'; +import { mockApi, mockCall } from 'app/core/testing/utils/mock-api.utils'; +import { IscsiTargetMode } from 'app/enums/iscsi.enum'; +import { FibreChannelPort } from 'app/interfaces/fibre-channel.interface'; +import { IscsiTarget } from 'app/interfaces/iscsi.interface'; +import { + AuthorizedNetworksCardComponent, +} from 'app/pages/sharing/iscsi/target/all-targets/target-details/authorized-networks-card/authorized-networks-card.component'; +import { + FibreChannelPortCardComponent, +} from 'app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component'; +import { ApiService } from 'app/services/websocket/api.service'; +import { TargetDetailsComponent } from './target-details.component'; + +describe('TargetDetailsComponent', () => { + let spectator: Spectator; + let mockApiService: jest.Mocked; + + const mockPort = { + id: 'Port-1', + wwpn: '10:00:00:00:c9:20:00:00', + wwpn_b: '10:00:00:00:c9:20:00:01', + } as unknown as FibreChannelPort; + + const createComponent = createComponentFactory({ + component: TargetDetailsComponent, + declarations: [ + MockComponents(AuthorizedNetworksCardComponent, FibreChannelPortCardComponent), + ], + providers: [ + mockApi([ + mockCall('fcport.query', [mockPort]), + mockCall('iscsi.extent.query', []), + mockCall('iscsi.targetextent.query', []), + ]), + ], + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + target: { + id: 1, + mode: IscsiTargetMode.Both, + auth_networks: ['192.168.1.0/24', '10.0.0.0/24'], + } as IscsiTarget, + }, + }); + + mockApiService = spectator.inject(ApiService); + }); + + it('renders AuthorizedNetworksCardComponent if target has authorized networks', () => { + expect(spectator.query(AuthorizedNetworksCardComponent)).toExist(); + expect(spectator.query(AuthorizedNetworksCardComponent)?.target).toEqual({ + id: 1, + mode: IscsiTargetMode.Both, + auth_networks: ['192.168.1.0/24', '10.0.0.0/24'], + }); + }); + + it('renders FibreChannelPortCardComponent if targetPort is set', () => { + spectator.detectChanges(); + expect(spectator.query(FibreChannelPortCardComponent)).toExist(); + expect(spectator.query(FibreChannelPortCardComponent)?.port).toEqual(mockPort); + }); + + it('does not render FibreChannelPortCardComponent if no targetPort is available', () => { + spectator.component.targetPort.set(null); + spectator.detectChanges(); + + expect(spectator.query(FibreChannelPortCardComponent)).toBeNull(); + }); + + it('calls API to fetch Fibre Channel ports when target ID changes', () => { + mockApiService.call.mockReturnValue(of([])); + spectator.setInput({ + target: { + id: 2, + mode: 'FC', + auth_networks: [], + } as IscsiTarget, + }); + + spectator.detectChanges(); + + expect(mockApiService.call).toHaveBeenCalledWith('fcport.query', [[['target.id', '=', 2]]]); + }); +}); diff --git a/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.ts b/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.ts index d54cd316e35..7d8720b1020 100644 --- a/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.ts +++ b/src/app/pages/sharing/iscsi/target/all-targets/target-details/target-details.component.ts @@ -1,13 +1,20 @@ import { - ChangeDetectionStrategy, Component, computed, input, + ChangeDetectionStrategy, Component, computed, effect, input, + signal, } from '@angular/core'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { take } from 'rxjs'; import { IscsiTargetMode } from 'app/enums/iscsi.enum'; +import { FibreChannelPort } from 'app/interfaces/fibre-channel.interface'; import { IscsiTarget } from 'app/interfaces/iscsi.interface'; import { AssociatedExtentsCardComponent } from 'app/pages/sharing/iscsi/target/all-targets/target-details/associated-extents-card/associated-extents-card.component'; import { AuthorizedNetworksCardComponent, } from 'app/pages/sharing/iscsi/target/all-targets/target-details/authorized-networks-card/authorized-networks-card.component'; +import { FibreChannelPortCardComponent } from 'app/pages/sharing/iscsi/target/all-targets/target-details/fibre-channel-port-card/fibre-channel-port-card.component'; +import { ApiService } from 'app/services/websocket/api.service'; +@UntilDestroy() @Component({ selector: 'ix-target-details', templateUrl: './target-details.component.html', @@ -15,11 +22,46 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AuthorizedNetworksCardComponent, + FibreChannelPortCardComponent, AssociatedExtentsCardComponent, ], }) export class TargetDetailsComponent { readonly target = input.required(); - protected hasIscsiCards = computed(() => [IscsiTargetMode.Iscsi, IscsiTargetMode.Both].includes(this.target().mode)); + targetPort = signal(null); + + protected hasIscsiCards = computed(() => [ + IscsiTargetMode.Iscsi, + IscsiTargetMode.Both, + ].includes(this.target().mode)); + + protected hasFibreCards = computed(() => [ + IscsiTargetMode.Fc, + IscsiTargetMode.Both, + ].includes(this.target().mode)); + + constructor( + private api: ApiService, + ) { + effect(() => { + const targetId = this.target().id; + this.targetPort.set(null); + + if (targetId) { + this.getPortByTargetId(targetId); + } + }, { allowSignalWrites: true }); + } + + private getPortByTargetId(id: number): void { + this.api.call('fcport.query', [[['target.id', '=', id]]]) + .pipe( + take(1), + untilDestroyed(this), + ) + .subscribe((ports) => { + this.targetPort.set(ports[0] || null); + }); + } } diff --git a/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.html b/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.html index 9c8e208ad98..ecc3dd20261 100644 --- a/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.html +++ b/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.html @@ -6,8 +6,6 @@

    {{ 'Targets' | translate }}

    - - diff --git a/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.spec.ts b/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.spec.ts index 0c01257f4f5..264cdb9cdc9 100644 --- a/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.spec.ts +++ b/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.spec.ts @@ -5,6 +5,7 @@ import { Spectator, createComponentFactory, mockProvider } from '@ngneat/spectat import { of } from 'rxjs'; import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils'; import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; +import { IscsiTargetMode } from 'app/enums/iscsi.enum'; import { IscsiTarget } from 'app/interfaces/iscsi.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { EmptyService } from 'app/modules/empty/empty.service'; @@ -20,10 +21,11 @@ import { TargetListComponent } from 'app/pages/sharing/iscsi/target/all-targets/ import { TargetFormComponent } from 'app/pages/sharing/iscsi/target/target-form/target-form.component'; import { SlideInService } from 'app/services/slide-in.service'; -const targets: IscsiTarget[] = [{ +const targets = [{ id: 1, name: 'test-iscsi-target', alias: 'test-iscsi-target-alias', + mode: IscsiTargetMode.Fc, } as IscsiTarget]; describe('TargetListComponent', () => { @@ -87,4 +89,16 @@ describe('TargetListComponent', () => { const cells = await table.getCellTexts(); expect(cells).toEqual(expectedRows); }); + + it('should show extra Mode column', async () => { + spectator.setInput('targets', targets); + + const expectedRows = [ + ['Name', 'Alias', 'Mode'], + ['test-iscsi-target', 'test-iscsi-target-alias', 'Fibre Channel'], + ]; + + const cells = await table.getCellTexts(); + expect(cells).toEqual(expectedRows); + }); }); diff --git a/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.ts b/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.ts index eb1df57b8b3..6d289c7bf48 100644 --- a/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.ts +++ b/src/app/pages/sharing/iscsi/target/all-targets/target-list/target-list.component.ts @@ -1,6 +1,6 @@ import { AsyncPipe } from '@angular/common'; import { - ChangeDetectionStrategy, ChangeDetectorRef, Component, input, OnInit, + ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, input, OnInit, output, } from '@angular/core'; import { MatButton } from '@angular/material/button'; @@ -11,6 +11,7 @@ import { TranslateService, TranslateModule } from '@ngx-translate/core'; import { filter } from 'rxjs/operators'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { UiSearchDirective } from 'app/directives/ui-search.directive'; +import { IscsiTargetMode, iscsiTargetModeNames } from 'app/enums/iscsi.enum'; import { Role } from 'app/enums/role.enum'; import { IscsiTarget } from 'app/interfaces/iscsi.interface'; import { EmptyService } from 'app/modules/empty/empty.service'; @@ -19,7 +20,6 @@ import { AsyncDataProvider } from 'app/modules/ix-table/classes/async-data-provi import { IxTableComponent } from 'app/modules/ix-table/components/ix-table/ix-table.component'; import { textColumn } from 'app/modules/ix-table/components/ix-table-body/cells/ix-cell-text/ix-cell-text.component'; import { IxTableBodyComponent } from 'app/modules/ix-table/components/ix-table-body/ix-table-body.component'; -import { IxTableColumnsSelectorComponent } from 'app/modules/ix-table/components/ix-table-columns-selector/ix-table-columns-selector.component'; import { IxTableHeadComponent } from 'app/modules/ix-table/components/ix-table-head/ix-table-head.component'; import { IxTablePagerComponent } from 'app/modules/ix-table/components/ix-table-pager/ix-table-pager.component'; import { IxTableEmptyDirective } from 'app/modules/ix-table/directives/ix-table-empty.directive'; @@ -42,7 +42,6 @@ import { SlideInService } from 'app/services/slide-in.service'; FakeProgressBarComponent, MatToolbarRow, SearchInput1Component, - IxTableColumnsSelectorComponent, RequiresRolesDirective, MatButton, TestDirective, @@ -59,9 +58,9 @@ import { SlideInService } from 'app/services/slide-in.service'; }) export class TargetListComponent implements OnInit { readonly isMobileView = input(); - readonly showMobileDetails = input(); readonly toggleShowMobileDetails = output(); readonly dataProvider = input>(); + readonly targets = input(); protected readonly searchableElements = targetListElements; @@ -82,6 +81,14 @@ export class TargetListComponent implements OnInit { title: this.translate.instant('Alias'), propertyName: 'alias', }), + textColumn({ + title: this.translate.instant('Mode'), + propertyName: 'mode', + hidden: true, + getValue: (row) => (iscsiTargetModeNames.has(row.mode) + ? this.translate.instant(iscsiTargetModeNames.get(row.mode)) + : row.mode || '-'), + }), ], { uniqueRowTag: (row) => 'iscsi-target-' + row.name, ariaLabels: (row) => [row.name, this.translate.instant('Target')], @@ -92,7 +99,24 @@ export class TargetListComponent implements OnInit { private slideInService: SlideInService, private translate: TranslateService, private cdr: ChangeDetectorRef, - ) {} + ) { + effect(() => { + if (this.targets()?.some((target) => target.mode !== IscsiTargetMode.Iscsi)) { + this.columns = this.columns.map((column) => { + if (column.propertyName === 'mode') { + return { + ...column, + hidden: false, + }; + } + + return column; + }); + this.cdr.detectChanges(); + this.cdr.markForCheck(); + } + }); + } ngOnInit(): void { this.setDefaultSort(); @@ -134,10 +158,4 @@ export class TargetListComponent implements OnInit { this.filterString = query; this.dataProvider().setFilter({ query, columnKeys: ['name'] }); } - - columnsChange(columns: typeof this.columns): void { - this.columns = [...columns]; - this.cdr.detectChanges(); - this.cdr.markForCheck(); - } } diff --git a/src/app/pages/sharing/iscsi/target/target-form/target-form.component.html b/src/app/pages/sharing/iscsi/target/target-form/target-form.component.html index ae1fcc612d6..450c4489824 100644 --- a/src/app/pages/sharing/iscsi/target/target-form/target-form.component.html +++ b/src/app/pages/sharing/iscsi/target/target-form/target-form.component.html @@ -21,6 +21,14 @@ [placeholder]="helptext.target_form_placeholder_alias | translate" > + @if (hasFibreChannel()) { + + } + { let spectator: Spectator; @@ -56,6 +60,19 @@ describe('TargetFormComponent', () => { IxIpInputWithNetmaskComponent, ], providers: [ + provideMockStore({ + selectors: [ + { + selector: selectSystemInfo, + value: { + version: 'TrueNAS-SCALE-22.12', + license: { + features: [LicenseFeature.FibreChannel], + }, + } as SystemInfo, + }, + ], + }), mockProvider(SlideInService), mockProvider(DialogService), mockProvider(SlideInRef), @@ -64,6 +81,7 @@ describe('TargetFormComponent', () => { mockCall('iscsi.target.create'), mockCall('iscsi.target.update'), mockCall('iscsi.target.validate_name', null), + mockCall('fc.capable', true), mockCall('iscsi.portal.query', [{ comment: 'comment_1', id: 1, @@ -183,6 +201,7 @@ describe('TargetFormComponent', () => { await form.fillForm({ 'Target Name': 'name_new', 'Target Alias': 'alias_new', + Mode: 'Fibre Channel', }); const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); @@ -195,7 +214,7 @@ describe('TargetFormComponent', () => { { name: 'name_new', alias: 'alias_new', - mode: 'ISCSI', + mode: IscsiTargetMode.Fc, groups: [ { portal: 1, @@ -226,9 +245,12 @@ describe('TargetFormComponent', () => { spectator.component.initiators$.subscribe((options) => initiator = options); spectator.component.auths$.subscribe((options) => auth = options); - expect(api.call).toHaveBeenNthCalledWith(1, 'iscsi.portal.query', []); - expect(api.call).toHaveBeenNthCalledWith(2, 'iscsi.initiator.query', []); - expect(api.call).toHaveBeenNthCalledWith(3, 'iscsi.auth.query', []); + expect(api.call).toHaveBeenNthCalledWith(1, 'fc.capable'); + expect(api.call).toHaveBeenNthCalledWith(2, 'iscsi.portal.query', []); + expect(api.call).toHaveBeenNthCalledWith(3, 'iscsi.initiator.query', []); + expect(api.call).toHaveBeenNthCalledWith(4, 'iscsi.auth.query', []); + + expect(spectator.component.hasFibreChannel()).toBe(true); expect(portal).toEqual([ { label: '1 (comment_1)', value: 1 }, diff --git a/src/app/pages/sharing/iscsi/target/target-form/target-form.component.ts b/src/app/pages/sharing/iscsi/target/target-form/target-form.component.ts index bd5248c6418..9200835535b 100644 --- a/src/app/pages/sharing/iscsi/target/target-form/target-form.component.ts +++ b/src/app/pages/sharing/iscsi/target/target-form/target-form.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnInit, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { Validators, ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; @@ -11,8 +12,9 @@ import { uniq } from 'lodash-es'; import { Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; -import { IscsiAuthMethod, IscsiTargetMode } from 'app/enums/iscsi.enum'; +import { IscsiAuthMethod, IscsiTargetMode, iscsiTargetModeNames } from 'app/enums/iscsi.enum'; import { Role } from 'app/enums/role.enum'; +import { mapToOptions } from 'app/helpers/options.helper'; import { helptextSharingIscsi } from 'app/helptext/sharing'; import { IscsiTarget, IscsiTargetGroup } from 'app/interfaces/iscsi.interface'; import { Option } from 'app/interfaces/option.interface'; @@ -22,6 +24,7 @@ import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input import { IxIpInputWithNetmaskComponent } from 'app/modules/forms/ix-forms/components/ix-ip-input-with-netmask/ix-ip-input-with-netmask.component'; import { IxListItemComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list-item/ix-list-item.component'; import { IxListComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list.component'; +import { IxRadioGroupComponent } from 'app/modules/forms/ix-forms/components/ix-radio-group/ix-radio-group.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; @@ -55,6 +58,7 @@ import { ApiService } from 'app/services/websocket/api.service'; MatButton, TestDirective, TranslateModule, + IxRadioGroupComponent, ], }) export class TargetFormComponent implements OnInit { @@ -72,6 +76,8 @@ export class TargetFormComponent implements OnInit { : this.translate.instant('Edit ISCSI Target'); } + hasFibreChannel = toSignal(this.iscsiService.hasFibreChannel()); + readonly helptext = helptextSharingIscsi; readonly portals$ = this.iscsiService.listPortals().pipe( map((portals) => { @@ -110,6 +116,8 @@ export class TargetFormComponent implements OnInit { }), ); + readonly modeOptions$ = of(mapToOptions(iscsiTargetModeNames, this.translate)); + readonly requiredRoles = [ Role.SharingIscsiTargetWrite, Role.SharingIscsiWrite, diff --git a/src/app/pages/sharing/iscsi/target/target-name-validation.service.ts b/src/app/pages/sharing/iscsi/target/target-name-validation.service.ts index 9749d2ff458..62d2482f46a 100644 --- a/src/app/pages/sharing/iscsi/target/target-name-validation.service.ts +++ b/src/app/pages/sharing/iscsi/target/target-name-validation.service.ts @@ -6,7 +6,6 @@ import { TranslateService } from '@ngx-translate/core'; import { Observable, catchError, debounceTime, distinctUntilChanged, of, switchMap, take, } from 'rxjs'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { ErrorReport } from 'app/interfaces/error-report.interface'; import { ErrorHandlerService } from 'app/services/error-handler.service'; import { ApiService } from 'app/services/websocket/api.service'; @@ -38,7 +37,7 @@ export class TargetNameValidationService { } return this.api.call('iscsi.target.validate_name', [value]).pipe( - catchError((error: ApiError) => { + catchError((error: unknown) => { const errorReports = this.errorHandler.parseError(error) as ErrorReport; return of({ customValidator: { diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts index ab3f5b7a8a7..33d9121d50c 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts @@ -564,7 +564,12 @@ describe('SmbFormComponent', () => { api = spectator.inject(ApiService); jest.spyOn(api, 'call').mockImplementation((method) => { if (method === 'sharing.smb.share_precheck') { - return throwError({ reason: '[EEXIST] sharing.smb.share_precheck.name: Share with this name already exists.' }); + return throwError(() => ({ + jsonrpc: '2.0', + error: { + data: { reason: '[EEXIST] sharing.smb.share_precheck.name: Share with this name already exists.' }, + }, + })); } return null; }); @@ -599,7 +604,13 @@ describe('SmbFormComponent', () => { case 'sharing.smb.presets': return of({ ...presets }); case 'sharing.smb.create': - return throwError({ reason: '[EINVAL] sharingsmb_create.afp: Apple SMB2/3 protocol extension support is required by this parameter.' }); + return throwError(() => ({ + error: { + data: { + reason: '[EINVAL] sharingsmb_create.afp: Apple SMB2/3 protocol extension support is required by this parameter.', + }, + }, + })); default: return of(null); } @@ -631,7 +642,13 @@ describe('SmbFormComponent', () => { await saveButton.click(); expect(spectator.inject(FormErrorHandlerService).handleValidationErrors).toHaveBeenCalledWith( - { reason: '[EINVAL] sharingsmb_create.afp: Apple SMB2/3 protocol extension support is required by this parameter.' }, + { + error: { + data: { + reason: '[EINVAL] sharingsmb_create.afp: Apple SMB2/3 protocol extension support is required by this parameter.', + }, + }, + }, spectator.component.form, {}, 'smb-form-toggle-advanced-options', diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts index 43d725be904..4f9f343ff55 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts @@ -29,8 +29,8 @@ import { DatasetPreset } from 'app/enums/dataset.enum'; import { Role } from 'app/enums/role.enum'; import { ServiceName } from 'app/enums/service-name.enum'; import { ServiceStatus } from 'app/enums/service-status.enum'; +import { extractApiError } from 'app/helpers/api.helper'; import { helptextSharingSmb } from 'app/helptext/sharing'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { DatasetCreate } from 'app/interfaces/dataset.interface'; import { Option } from 'app/interfaces/option.interface'; import { @@ -518,8 +518,9 @@ export class SmbFormComponent implements OnInit, AfterViewInit { this.slideInRef.close(true); } }, - error: (error: ApiError) => { - if (error?.reason?.includes('[ENOENT]') || error?.reason?.includes('[EXDEV]')) { + error: (error: unknown) => { + const apiError = extractApiError(error); + if (apiError?.reason?.includes('[ENOENT]') || apiError?.reason?.includes('[EXDEV]')) { this.dialogService.closeAllDialogs(); } this.isLoading = false; diff --git a/src/app/pages/sharing/smb/smb-form/smb-validator.service.ts b/src/app/pages/sharing/smb/smb-form/smb-validator.service.ts index 1b897f62e27..84f802f10de 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-validator.service.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-validator.service.ts @@ -7,6 +7,7 @@ import { TranslateService } from '@ngx-translate/core'; import { Observable, catchError, debounceTime, distinctUntilChanged, of, switchMap, take, } from 'rxjs'; +import { extractApiError } from 'app/helpers/api.helper'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { ApiService } from 'app/services/websocket/api.service'; @@ -39,19 +40,20 @@ export class SmbValidationService { return this.api.call('sharing.smb.share_precheck', [{ name: value }]).pipe( switchMap((response) => this.handleError(response)), - catchError((error: { reason: string }) => this.handleError(error)), + catchError((error: unknown) => this.handleError(error)), ); }), ); }; }; - private handleError(error: { reason: string }): Observable { + private handleError(error: unknown): Observable { if (error === null) { return of(null); } - const errorText = this.extractError(error.reason); + const apiError = extractApiError(error); + const errorText = this.extractError(apiError?.reason || ''); if (errorText === this.noSmbUsersError) { this.showNoSmbUsersWarning(); diff --git a/src/app/pages/signin/store/signin.store.spec.ts b/src/app/pages/signin/store/signin.store.spec.ts index d248c5bcce8..2e822d74c89 100644 --- a/src/app/pages/signin/store/signin.store.spec.ts +++ b/src/app/pages/signin/store/signin.store.spec.ts @@ -1,10 +1,10 @@ -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { createServiceFactory, SpectatorService } from '@ngneat/spectator'; import { mockProvider } from '@ngneat/spectator/jest'; import { BehaviorSubject, firstValueFrom, of } from 'rxjs'; import { MockApiService } from 'app/core/testing/classes/mock-api.service'; import { getTestScheduler } from 'app/core/testing/utils/get-test-scheduler.utils'; -import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils'; +import { mockApi, mockCall } from 'app/core/testing/utils/mock-api.utils'; import { FailoverDisabledReason } from 'app/enums/failover-disabled-reason.enum'; import { FailoverStatus } from 'app/enums/failover-status.enum'; import { LoginResult } from 'app/enums/login-result.enum'; @@ -66,6 +66,7 @@ describe('SigninStore', () => { }, }, }, + mockProvider(ActivatedRoute, { snapshot: { queryParamMap: { get: jest.fn(() => null) } } }), ], }); @@ -82,6 +83,7 @@ describe('SigninStore', () => { }); jest.spyOn(authService, 'loginWithToken').mockReturnValue(of(LoginResult.Success)); jest.spyOn(authService, 'clearAuthToken').mockReturnValue(null); + jest.spyOn(authService, 'setQueryToken').mockReturnValue(undefined); }); describe('selectors', () => { @@ -187,7 +189,7 @@ describe('SigninStore', () => { expect(spectator.inject(Router).navigateByUrl).toHaveBeenCalledWith('/dashboard'); }); - it('should not call "loginWithToken" if token is not within timeline and clear auth token', () => { + it('should not call "loginWithToken" if token is not within timeline and clear auth token and queryToken is null', () => { isTokenWithinTimeline$.next(false); spectator.service.init(); @@ -195,6 +197,16 @@ describe('SigninStore', () => { expect(authService.loginWithToken).not.toHaveBeenCalled(); expect(spectator.inject(Router).navigateByUrl).not.toHaveBeenCalled(); }); + + it('should call "loginWithToken" if queryToken is not null', () => { + isTokenWithinTimeline$.next(false); + const token = 'token'; + const activatedRoute = spectator.inject(ActivatedRoute); + jest.spyOn(activatedRoute.snapshot.queryParamMap, 'get').mockImplementationOnce(() => token); + spectator.service.init(); + expect(authService.setQueryToken).toHaveBeenCalledWith(token); + expect(authService.loginWithToken).toHaveBeenCalled(); + }); }); describe('init - failover subscriptions', () => { diff --git a/src/app/pages/signin/store/signin.store.ts b/src/app/pages/signin/store/signin.store.ts index 432f18638ce..290c273350a 100644 --- a/src/app/pages/signin/store/signin.store.ts +++ b/src/app/pages/signin/store/signin.store.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { ComponentStore } from '@ngrx/component-store'; import { Actions, ofType } from '@ngrx/effects'; @@ -68,6 +68,14 @@ export class SigninStore extends ComponentStore { private failoverStatusSubscription: Subscription; private disabledReasonsSubscription: Subscription; + private handleLoginResult = (loginResult: LoginResult): void => { + if (loginResult !== LoginResult.Success) { + this.authService.clearAuthToken(); + } else { + this.handleSuccessfulLogin(); + } + }; + constructor( private api: ApiService, private translate: TranslateService, @@ -81,6 +89,7 @@ export class SigninStore extends ComponentStore { private authService: AuthService, private updateService: UpdateService, private actions$: Actions, + private activatedRoute: ActivatedRoute, @Inject(WINDOW) private window: Window, ) { super(initialState); @@ -97,7 +106,14 @@ export class SigninStore extends ComponentStore { this.updateService.hardRefreshIfNeeded(), ])), tap(() => this.setLoadingState(false)), - switchMap(() => this.handleLoginWithToken()), + switchMap(() => { + const queryToken = this.activatedRoute.snapshot.queryParamMap.get('token'); + if (queryToken) { + return this.handleLoginWithQueryToken(queryToken); + } + + return this.handleLoginWithToken(); + }), )); handleSuccessfulLogin = this.effect((trigger$: Observable) => trigger$.pipe( @@ -188,7 +204,7 @@ export class SigninStore extends ComponentStore { private checkIfAdminPasswordSet(): Observable { return this.api.call('user.has_local_administrator_set_up').pipe( tap((wasAdminSet) => this.patchState({ wasAdminSet })), - catchError((error) => { + catchError((error: unknown) => { this.errorHandler.showErrorModal(error); return of(initialState.wasAdminSet); }), @@ -207,7 +223,7 @@ export class SigninStore extends ComponentStore { this.subscribeToFailoverUpdates(); return this.loadAdditionalFailoverInfo(); }), - catchError((error) => { + catchError((error: unknown) => { this.errorHandler.showErrorModal(error); return of(undefined); }), @@ -239,8 +255,23 @@ export class SigninStore extends ComponentStore { .subscribe((event) => this.setFailoverDisabledReasons(event.disabled_reasons)); } + private handleLoginWithQueryToken(token: string): Observable { + this.authService.setQueryToken(token); + + return this.authService.loginWithToken().pipe( + tap(this.handleLoginResult.bind(this)), + tapResponse( + () => {}, + (error: unknown) => { + this.dialogService.error(this.errorHandler.parseError(error)); + }, + ), + ); + } + private handleLoginWithToken(): Observable { - return this.tokenLastUsedService.isTokenWithinTimeline$.pipe(take(1)).pipe( + return this.tokenLastUsedService.isTokenWithinTimeline$.pipe( + take(1), filter((isTokenWithinTimeline) => { if (!isTokenWithinTimeline) { this.authService.clearAuthToken(); @@ -249,13 +280,7 @@ export class SigninStore extends ComponentStore { return isTokenWithinTimeline; }), switchMap(() => this.authService.loginWithToken()), - tap((loginResult) => { - if (loginResult !== LoginResult.Success) { - this.authService.clearAuthToken(); - } else { - this.handleSuccessfulLogin(); - } - }), + tap(this.handleLoginResult.bind(this)), tapResponse( () => {}, (error: unknown) => { diff --git a/src/app/pages/storage/components/dashboard-pool/export-disconnect-modal/export-disconnect-modal.component.ts b/src/app/pages/storage/components/dashboard-pool/export-disconnect-modal/export-disconnect-modal.component.ts index b138e643265..5c293daacfc 100644 --- a/src/app/pages/storage/components/dashboard-pool/export-disconnect-modal/export-disconnect-modal.component.ts +++ b/src/app/pages/storage/components/dashboard-pool/export-disconnect-modal/export-disconnect-modal.component.ts @@ -18,7 +18,6 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r import { PoolStatus } from 'app/enums/pool-status.enum'; import { Role } from 'app/enums/role.enum'; import { helptextVolumes } from 'app/helptext/storage/volumes/volume-list'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { Job } from 'app/interfaces/job.interface'; import { PoolAttachment } from 'app/interfaces/pool-attachment.interface'; import { Pool } from 'app/interfaces/pool.interface'; @@ -34,6 +33,7 @@ import { AppLoaderService } from 'app/modules/loader/app-loader.service'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { TestDirective } from 'app/modules/test-id/test.directive'; import { DatasetTreeStore } from 'app/pages/datasets/store/dataset-store.service'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; import { ApiService } from 'app/services/websocket/api.service'; @UntilDestroy() @@ -118,6 +118,7 @@ export class ExportDisconnectModalComponent implements OnInit { private datasetStore: DatasetTreeStore, private cdr: ChangeDetectorRef, private snackbar: SnackbarService, + private errorHandler: ErrorHandlerService, @Inject(MAT_DIALOG_DATA) public pool: Pool, ) {} @@ -274,22 +275,17 @@ export class ExportDisconnectModalComponent implements OnInit { this.api.call('pool.processes', [this.pool.id]), this.api.call('systemdataset.config'), ]) - .pipe(this.loader.withLoader(), untilDestroyed(this)) - .subscribe({ - next: ([attachments, processes, systemConfig]) => { - this.attachments = attachments; - this.processes = processes; - this.systemConfig = systemConfig; - this.prepareForm(); - this.cdr.markForCheck(); - }, - error: (error: ApiError) => { - this.dialogService.error({ - title: helptextVolumes.exportError, - message: error.reason, - backtrace: error.trace?.formatted, - }); - }, + .pipe( + this.loader.withLoader(), + this.errorHandler.catchError(), + untilDestroyed(this), + ) + .subscribe(([attachments, processes, systemConfig]) => { + this.attachments = attachments; + this.processes = processes; + this.systemConfig = systemConfig; + this.prepareForm(); + this.cdr.markForCheck(); }); } diff --git a/src/app/pages/storage/components/dashboard-pool/zfs-health-card/zfs-health-card.component.spec.ts b/src/app/pages/storage/components/dashboard-pool/zfs-health-card/zfs-health-card.component.spec.ts index b7562320336..8a981150395 100644 --- a/src/app/pages/storage/components/dashboard-pool/zfs-health-card/zfs-health-card.component.spec.ts +++ b/src/app/pages/storage/components/dashboard-pool/zfs-health-card/zfs-health-card.component.spec.ts @@ -10,7 +10,7 @@ import { MockComponent } from 'ng-mocks'; import { of, Subject } from 'rxjs'; import { FakeFormatDateTimePipe } from 'app/core/testing/classes/fake-format-datetime.pipe'; import { mockAuth } from 'app/core/testing/utils/mock-auth.utils'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { PoolCardIconType } from 'app/enums/pool-card-icon-type.enum'; import { PoolScanFunction } from 'app/enums/pool-scan-function.enum'; import { PoolScanState } from 'app/enums/pool-scan-state.enum'; @@ -162,7 +162,7 @@ describe('ZfsHealthCardComponent', () => { websocketSubscription$.next({ id: 2, collection: 'zfs.pool.scan', - msg: IncomingApiMessageType.Changed, + msg: CollectionChangeType.Changed, fields: { name: 'tank', scan: activeScrub, @@ -225,7 +225,7 @@ describe('ZfsHealthCardComponent', () => { websocketSubscription$.next({ id: 2, collection: 'zfs.pool.scan', - msg: IncomingApiMessageType.Changed, + msg: CollectionChangeType.Changed, fields: { name: 'tank', scan: activeScrub, @@ -243,7 +243,7 @@ describe('ZfsHealthCardComponent', () => { websocketSubscription$.next({ id: 2, collection: 'zfs.pool.scan', - msg: IncomingApiMessageType.Changed, + msg: CollectionChangeType.Changed, fields: { name: 'tank', scan: { diff --git a/src/app/pages/storage/components/import-pool/import-pool.component.ts b/src/app/pages/storage/components/import-pool/import-pool.component.ts index 3f85f2719ed..bfd84fe0e94 100644 --- a/src/app/pages/storage/components/import-pool/import-pool.component.ts +++ b/src/app/pages/storage/components/import-pool/import-pool.component.ts @@ -16,9 +16,7 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r import { JobState } from 'app/enums/job-state.enum'; import { Role } from 'app/enums/role.enum'; import { helptextImport } from 'app/helptext/storage/volumes/volume-import-wizard'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { Dataset } from 'app/interfaces/dataset.interface'; -import { Job } from 'app/interfaces/job.interface'; import { Option } from 'app/interfaces/option.interface'; import { PoolFindResult } from 'app/interfaces/pool-import.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; @@ -108,7 +106,7 @@ export class ImportPoolComponent implements OnInit { this.pool.options = of(opts); this.cdr.markForCheck(); }, - error: (error: ApiError | Job) => { + error: (error: unknown) => { this.isLoading = false; this.cdr.markForCheck(); diff --git a/src/app/pages/storage/modules/devices/stores/devices-store.service.ts b/src/app/pages/storage/modules/devices/stores/devices-store.service.ts index cfed76f3265..7ce8de4fe74 100644 --- a/src/app/pages/storage/modules/devices/stores/devices-store.service.ts +++ b/src/app/pages/storage/modules/devices/stores/devices-store.service.ts @@ -5,7 +5,6 @@ import { keyBy } from 'lodash-es'; import { EMPTY, Observable, of } from 'rxjs'; import { catchError, switchMap, tap } from 'rxjs/operators'; import { VdevType } from 'app/enums/v-dev-type.enum'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { DeviceNestedDataNode, VDevGroup } from 'app/interfaces/device-nested-data-node.interface'; import { Disk } from 'app/interfaces/disk.interface'; import { PoolTopology } from 'app/interfaces/pool.interface'; @@ -16,7 +15,7 @@ import { ApiService } from 'app/services/websocket/api.service'; export interface DevicesState { isLoading: boolean; poolId: number | null; - error: ApiError | null; + error: unknown; nodes: DeviceNestedDataNode[]; diskDictionary: Record; selectedNodeGuid: string | null; @@ -92,7 +91,7 @@ export class DevicesStore extends ComponentStore { nodes: this.createDataNodes(pools[0].topology), }); }), - catchError((error: ApiError) => { + catchError((error: unknown) => { this.patchState({ isLoading: false, error, diff --git a/src/app/pages/storage/modules/disks/components/manual-test-dialog/test-progress-row/test-progress-row.component.spec.ts b/src/app/pages/storage/modules/disks/components/manual-test-dialog/test-progress-row/test-progress-row.component.spec.ts index d2125aaa287..58c8f74a8aa 100644 --- a/src/app/pages/storage/modules/disks/components/manual-test-dialog/test-progress-row/test-progress-row.component.spec.ts +++ b/src/app/pages/storage/modules/disks/components/manual-test-dialog/test-progress-row/test-progress-row.component.spec.ts @@ -3,7 +3,7 @@ import { mockProvider, Spectator, createComponentFactory } from '@ngneat/spectat import { of } from 'rxjs'; import { MockApiService } from 'app/core/testing/classes/mock-api.service'; import { mockApi } from 'app/core/testing/utils/mock-api.utils'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { SmartTestType } from 'app/enums/smart-test-type.enum'; import { Disk } from 'app/interfaces/disk.interface'; import { SmartTestProgressUpdate } from 'app/interfaces/smart-test-progress.interface'; @@ -105,7 +105,7 @@ describe('IxTestProgressRowComponent', () => { it('shows progress change', () => { const websocketMock = spectator.inject(MockApiService); websocketMock.emitSubscribeEvent({ - msg: IncomingApiMessageType.Added, + msg: CollectionChangeType.Added, collection: 'smart.test.progerss:sdd', fields: { progress: 15, @@ -121,7 +121,7 @@ describe('IxTestProgressRowComponent', () => { it('shows success icon when test is done', () => { const websocketMock = spectator.inject(MockApiService); websocketMock.emitSubscribeEvent({ - msg: IncomingApiMessageType.Added, + msg: CollectionChangeType.Added, collection: 'smart.test.progerss:sdd', fields: { progress: 15, @@ -129,7 +129,7 @@ describe('IxTestProgressRowComponent', () => { }); spectator.detectChanges(); websocketMock.emitSubscribeEvent({ - msg: IncomingApiMessageType.Added, + msg: CollectionChangeType.Added, collection: 'smart.test.progerss:sdd', fields: { progress: null, diff --git a/src/app/pages/storage/modules/disks/components/manual-test-dialog/test-progress-row/test-progress-row.component.ts b/src/app/pages/storage/modules/disks/components/manual-test-dialog/test-progress-row/test-progress-row.component.ts index b20fa47ab15..90fe9cab8dc 100644 --- a/src/app/pages/storage/modules/disks/components/manual-test-dialog/test-progress-row/test-progress-row.component.ts +++ b/src/app/pages/storage/modules/disks/components/manual-test-dialog/test-progress-row/test-progress-row.component.ts @@ -7,7 +7,6 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { catchError, EMPTY, map, Observable, Subscription, takeWhile, tap, } from 'rxjs'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; import { SmartTestType } from 'app/enums/smart-test-type.enum'; import { Disk } from 'app/interfaces/disk.interface'; import { SmartTestProgressUi } from 'app/interfaces/smart-test-progress-ui.interface'; @@ -110,7 +109,6 @@ export class TestProgressRowComponent implements OnInit { return EMPTY; }), takeWhile((result) => { - const isNoSubMsg = result && result.msg === IncomingApiMessageType.NoSub; const testProgress = this.test().progressPercentage; let isProgressing: boolean; if (result.fields.progress == null) { @@ -122,13 +120,13 @@ export class TestProgressRowComponent implements OnInit { } else { isProgressing = false; } - if (isNoSubMsg || !isProgressing) { + if (!isProgressing) { this.test.set({ ...this.test(), finished: true, }); } - return !isNoSubMsg && isProgressing; + return isProgressing; }), map((apiEvent) => apiEvent.fields), tap((progressUpdate) => { diff --git a/src/app/pages/storage/modules/disks/components/smart-test-result-list/smart-test-result-list.component.ts b/src/app/pages/storage/modules/disks/components/smart-test-result-list/smart-test-result-list.component.ts index 1486f89b014..5f2fc25f29e 100644 --- a/src/app/pages/storage/modules/disks/components/smart-test-result-list/smart-test-result-list.component.ts +++ b/src/app/pages/storage/modules/disks/components/smart-test-result-list/smart-test-result-list.component.ts @@ -1,7 +1,6 @@ import { AsyncPipe } from '@angular/common'; import { - ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, - input, + ChangeDetectionStrategy, ChangeDetectorRef, Component, input, OnInit, } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService, TranslateModule } from '@ngx-translate/core'; @@ -50,8 +49,9 @@ import { ApiService } from 'app/services/websocket/api.service'; ], }) export class SmartTestResultListComponent implements OnInit { - readonly type = input(undefined); - readonly pk = input(undefined); + readonly type = input(); + readonly pk = input(); + disks: Disk[] = []; smartTestResults: SmartTestResultsRow[]; filterString = ''; diff --git a/src/app/pages/storage/modules/pool-manager/components/download-key-dialog/download-key-dialog.component.spec.ts b/src/app/pages/storage/modules/pool-manager/components/download-key-dialog/download-key-dialog.component.spec.ts index 2519a32a796..30487d89f96 100644 --- a/src/app/pages/storage/modules/pool-manager/components/download-key-dialog/download-key-dialog.component.spec.ts +++ b/src/app/pages/storage/modules/pool-manager/components/download-key-dialog/download-key-dialog.component.spec.ts @@ -99,7 +99,7 @@ describe('DownloadKeyDialogComponent', () => { await downloadButton.click(); expect(spectator.inject(AppLoaderService).close).toHaveBeenCalled(); - expect(spectator.inject(DialogService).error).toHaveBeenCalledWith('Parsed HTTP error'); + expect(spectator.inject(ErrorHandlerService).parseError).toHaveBeenCalled(); const doneButton = await loader.getHarness(MatButtonHarness.with({ text: 'Done' })); expect(await doneButton.isDisabled()).toBe(false); diff --git a/src/app/pages/storage/modules/pool-manager/components/download-key-dialog/download-key-dialog.component.ts b/src/app/pages/storage/modules/pool-manager/components/download-key-dialog/download-key-dialog.component.ts index e0600f3b873..defc6a80315 100644 --- a/src/app/pages/storage/modules/pool-manager/components/download-key-dialog/download-key-dialog.component.ts +++ b/src/app/pages/storage/modules/pool-manager/components/download-key-dialog/download-key-dialog.component.ts @@ -1,4 +1,3 @@ -import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, Inject, signal, @@ -69,8 +68,8 @@ export class DownloadKeyDialogComponent { this.download.downloadBlob(file, this.filename); this.wasDownloaded.set(true); }), - catchError((error: HttpErrorResponse) => { - this.dialog.error(this.errorHandler.parseHttpError(error)); + catchError((error: unknown) => { + this.dialog.error(this.errorHandler.parseError(error)); this.wasDownloaded.set(true); return EMPTY; }), @@ -78,7 +77,7 @@ export class DownloadKeyDialogComponent { }), untilDestroyed(this), ).subscribe({ - error: (error) => { + error: (error: unknown) => { this.loader.close(); this.dialog.error(this.errorHandler.parseError(error)); }, diff --git a/src/app/pages/storage/modules/pool-manager/components/manual-disk-selection/components/manual-selection-vdev/manual-selection-vdev.component.html b/src/app/pages/storage/modules/pool-manager/components/manual-disk-selection/components/manual-selection-vdev/manual-selection-vdev.component.html index 8a6fd568a66..b13aa53038b 100644 --- a/src/app/pages/storage/modules/pool-manager/components/manual-disk-selection/components/manual-selection-vdev/manual-selection-vdev.component.html +++ b/src/app/pages/storage/modules/pool-manager/components/manual-disk-selection/components/manual-selection-vdev/manual-selection-vdev.component.html @@ -23,7 +23,7 @@

    {{ layout() }}

    @for (enclosureDisks of enclosuresDisks | keyvalue; track enclosureDisks) {
    @for (disk of enclosureDisks.value; track disk) { diff --git a/src/app/pages/storage/modules/pool-manager/components/manual-disk-selection/components/manual-selection-vdev/manual-selection-vdev.component.ts b/src/app/pages/storage/modules/pool-manager/components/manual-disk-selection/components/manual-selection-vdev/manual-selection-vdev.component.ts index 5ecdcd7b55b..4548a9c7c63 100644 --- a/src/app/pages/storage/modules/pool-manager/components/manual-disk-selection/components/manual-selection-vdev/manual-selection-vdev.component.ts +++ b/src/app/pages/storage/modules/pool-manager/components/manual-disk-selection/components/manual-selection-vdev/manual-selection-vdev.component.ts @@ -1,6 +1,6 @@ import { NgClass, AsyncPipe, KeyValuePipe } from '@angular/common'; import { - ChangeDetectionStrategy, ChangeDetectorRef, Component, input, Input, OnChanges, + ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, input, OnChanges, } from '@angular/core'; import { MatCard, MatCardContent } from '@angular/material/card'; import { UntilDestroy } from '@ngneat/until-destroy'; @@ -54,11 +54,12 @@ export class ManualSelectionVdevComponent implements OnChanges { readonly layout = input(); readonly editable = input(false); - @Input() set enclosures(enclosures: Enclosure[]) { - this.enclosureById = keyBy(enclosures, 'id'); - } + readonly enclosures = input(); + + readonly enclosureById = computed(() => { + return keyBy(this.enclosures(), 'id') as Record; + }); - protected enclosureById: Record = {}; protected sizeEstimation = 0; protected vdevErrorMessage = ''; diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/1-general-wizard-step/pool-wizard-name-validation.service.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/1-general-wizard-step/pool-wizard-name-validation.service.ts index dcf5d441ba7..e0404cf62a6 100644 --- a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/1-general-wizard-step/pool-wizard-name-validation.service.ts +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/1-general-wizard-step/pool-wizard-name-validation.service.ts @@ -39,7 +39,7 @@ export class PoolWizardNameValidationService { invalidPoolName: true, }); }), - catchError((error) => { + catchError((error: unknown) => { const errorReports = this.errorHandler.parseError(error) as ErrorReport; return of({ customValidator: { diff --git a/src/app/pages/storage/stores/pools-dashboard-store.service.ts b/src/app/pages/storage/stores/pools-dashboard-store.service.ts index 38a95e9dce6..25cce107229 100644 --- a/src/app/pages/storage/stores/pools-dashboard-store.service.ts +++ b/src/app/pages/storage/stores/pools-dashboard-store.service.ts @@ -8,7 +8,6 @@ import { import { catchError, switchMap } from 'rxjs/operators'; import { SmartTestResultStatus } from 'app/enums/smart-test-result-status.enum'; import { Alert } from 'app/interfaces/alert.interface'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { Dataset } from 'app/interfaces/dataset.interface'; import { Disk, DiskTemperatureAgg, StorageDashboardDisk } from 'app/interfaces/disk.interface'; import { Pool } from 'app/interfaces/pool.interface'; @@ -167,7 +166,7 @@ export class PoolsDashboardStore extends ComponentStore { getDiskTempAggregates(disksNames: string[]): Observable { return this.api.call('disk.temperature_agg', [disksNames, 14]).pipe( - catchError((error: ApiError) => { + catchError((error: unknown) => { console.error('Error loading temperature: ', error); return of({}); }), diff --git a/src/app/pages/system/advanced/access/access-form/access-form.component.ts b/src/app/pages/system/advanced/access/access-form/access-form.component.ts index db6872c1d39..e5fab71c01b 100644 --- a/src/app/pages/system/advanced/access/access-form/access-form.component.ts +++ b/src/app/pages/system/advanced/access/access-form/access-form.component.ts @@ -137,7 +137,7 @@ export class AccessFormComponent implements OnInit { this.showSuccessNotificationAndClose(); this.cdr.markForCheck(); }, - error: (error) => { + error: (error: unknown) => { this.isLoading = false; this.dialogService.error(this.errorHandler.parseError(error)); this.cdr.markForCheck(); diff --git a/src/app/pages/system/advanced/allowed-addresses/allowed-addresses-form/allowed-addresses-form.component.ts b/src/app/pages/system/advanced/allowed-addresses/allowed-addresses-form/allowed-addresses-form.component.ts index e93a97c0684..5fe46fce75c 100644 --- a/src/app/pages/system/advanced/allowed-addresses/allowed-addresses-form/allowed-addresses-form.component.ts +++ b/src/app/pages/system/advanced/allowed-addresses/allowed-addresses-form/allowed-addresses-form.component.ts @@ -8,14 +8,13 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { Store } from '@ngrx/store'; import { TranslateService, TranslateModule } from '@ngx-translate/core'; import { - EMPTY, Observable, of, switchMap, tap, + Observable, of, switchMap, tap, } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { Role } from 'app/enums/role.enum'; import { helptextSystemAdvanced } from 'app/helptext/system/advanced'; import { helptextSystemGeneral } from 'app/helptext/system/general'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; @@ -112,14 +111,7 @@ export class AllowedAddressesFormComponent implements OnInit { return of(true); } return this.api.call('system.general.ui_restart').pipe( - catchError((error: ApiError) => { - this.dialogService.error({ - title: helptextSystemGeneral.dialog_error_title, - message: error.reason, - backtrace: error.trace?.formatted, - }); - return EMPTY; - }), + this.errorHandler.catchError(), map(() => true), ); }), diff --git a/src/app/pages/system/advanced/global-two-factor-auth/global-two-factor-form/global-two-factor-form.component.ts b/src/app/pages/system/advanced/global-two-factor-auth/global-two-factor-form/global-two-factor-form.component.ts index aea90c4c6e7..341f9dc7a8c 100644 --- a/src/app/pages/system/advanced/global-two-factor-auth/global-two-factor-form/global-two-factor-form.component.ts +++ b/src/app/pages/system/advanced/global-two-factor-auth/global-two-factor-form/global-two-factor-form.component.ts @@ -127,7 +127,7 @@ export class GlobalTwoFactorAuthFormComponent implements OnInit { this.cdr.markForCheck(); this.chainedRef.close({ response: true, error: null }); }), - catchError((error) => { + catchError((error: unknown) => { this.isFormLoading = false; this.dialogService.error(this.errorHandler.parseError(error)); this.cdr.markForCheck(); diff --git a/src/app/pages/system/enclosure/components/identify-light/identify-light.component.ts b/src/app/pages/system/enclosure/components/identify-light/identify-light.component.ts index f29571565d1..67c8dd7cfc8 100644 --- a/src/app/pages/system/enclosure/components/identify-light/identify-light.component.ts +++ b/src/app/pages/system/enclosure/components/identify-light/identify-light.component.ts @@ -56,7 +56,7 @@ export class IdentifyLightComponent { slot: slot.drive_bay_number, }]) .pipe( - catchError((error) => { + catchError((error: unknown) => { this.errorHandler.showErrorModal(error); this.store.changeLightStatus({ status: oldStatus, diff --git a/src/app/pages/system/enclosure/services/enclosure.store.spec.ts b/src/app/pages/system/enclosure/services/enclosure.store.spec.ts index 22676199a88..9586c77942d 100644 --- a/src/app/pages/system/enclosure/services/enclosure.store.spec.ts +++ b/src/app/pages/system/enclosure/services/enclosure.store.spec.ts @@ -6,7 +6,7 @@ import { } from '@ngneat/spectator/jest'; import { MockApiService } from 'app/core/testing/classes/mock-api.service'; import { mockCall, mockApi } from 'app/core/testing/utils/mock-api.utils'; -import { IncomingApiMessageType } from 'app/enums/api-message-type.enum'; +import { CollectionChangeType } from 'app/enums/api.enum'; import { EnclosureElementType } from 'app/enums/enclosure-slot-status.enum'; import { DashboardEnclosure, @@ -85,7 +85,7 @@ describe('EnclosureStore', () => { spectator.service.listenForDiskUpdates().subscribe(); spectator.inject(MockApiService).emitSubscribeEvent({ - msg: IncomingApiMessageType.Changed, + msg: CollectionChangeType.Changed, collection: 'disk.query', }); diff --git a/src/app/pages/system/general-settings/gui/gui-form/gui-form.component.ts b/src/app/pages/system/general-settings/gui/gui-form/gui-form.component.ts index dc38498239d..d03ac555944 100644 --- a/src/app/pages/system/general-settings/gui/gui-form/gui-form.component.ts +++ b/src/app/pages/system/general-settings/gui/gui-form/gui-form.component.ts @@ -15,7 +15,6 @@ import { import { choicesToOptions } from 'app/helpers/operators/options.operators'; import { WINDOW } from 'app/helpers/window.helper'; import { helptextSystemGeneral as helptext } from 'app/helptext/system/general'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { SystemGeneralConfig, SystemGeneralConfigUpdate } from 'app/interfaces/system-config.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; @@ -30,6 +29,7 @@ import { AppLoaderService } from 'app/modules/loader/app-loader.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { TestDirective } from 'app/modules/test-id/test.directive'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; import { SystemGeneralService } from 'app/services/system-general.service'; import { ThemeService } from 'app/services/theme/theme.service'; import { ApiService } from 'app/services/websocket/api.service'; @@ -100,7 +100,8 @@ export class GuiFormComponent { private dialog: DialogService, private loader: AppLoaderService, private translate: TranslateService, - private errorHandler: FormErrorHandlerService, + private formErrorHandler: FormErrorHandlerService, + private errorHandler: ErrorHandlerService, private store$: Store, @Inject(WINDOW) private window: Window, ) { @@ -154,7 +155,7 @@ export class GuiFormComponent { }, error: (error: unknown) => { this.isFormLoading = false; - this.errorHandler.handleValidationErrors(error, this.formGroup); + this.formErrorHandler.handleValidationErrors(error, this.formGroup); this.cdr.markForCheck(); }, }); @@ -220,13 +221,9 @@ export class GuiFormComponent { this.wsManager.reconnect(); this.replaceHrefWhenWsConnected(href); }, - error: (error: ApiError) => { + error: (error: unknown) => { this.loader.close(); - this.dialog.error({ - title: helptext.dialog_error_title, - message: error.reason, - backtrace: error.trace?.formatted, - }); + this.errorHandler.showErrorModal(error); }, }); }); diff --git a/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts b/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts index 7d70138951d..74606662ce3 100644 --- a/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts +++ b/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts @@ -21,6 +21,7 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r import { UiSearchDirective } from 'app/directives/ui-search.directive'; import { JobState } from 'app/enums/job-state.enum'; import { Role } from 'app/enums/role.enum'; +import { isFailedJob } from 'app/helpers/api.helper'; import { observeJob } from 'app/helpers/operators/observe-job.operator'; import { helptextSystemUpdate as helptext } from 'app/helptext/system/update'; import { ApiJobMethod } from 'app/interfaces/api/api-job-directory.interface'; @@ -174,7 +175,7 @@ export class ManualUpdateFormComponent implements OnInit { this.showRunningUpdate(jobs[0].id); } }, - error: (err) => { + error: (err: unknown) => { console.error(err); }, }); @@ -245,7 +246,7 @@ export class ManualUpdateFormComponent implements OnInit { ) .subscribe({ next: () => this.handleUpdateSuccess(), - error: (job: Job) => this.handleUpdateFailure(job), + error: (error: unknown) => this.handleUpdateFailure(error), }); } @@ -285,10 +286,11 @@ export class ManualUpdateFormComponent implements OnInit { } } - handleUpdateFailure = (failure: Job): void => { + handleUpdateFailure = (failure: unknown): void => { this.isFormLoading$.next(false); this.cdr.markForCheck(); - if (failure.error.includes(updateAgainCode)) { + + if (isFailedJob(failure) && failure.error.includes(updateAgainCode)) { this.dialogService.confirm({ title: helptext.continueDialogTitle, message: failure.error.replace(updateAgainCode, ''), @@ -315,7 +317,7 @@ export class ManualUpdateFormComponent implements OnInit { .pipe(untilDestroyed(this)) .subscribe({ next: () => this.handleUpdateSuccess(), - error: (job: Job) => this.handleUpdateFailure(job), + error: (error: unknown) => this.handleUpdateFailure(error), }); } } diff --git a/src/app/pages/system/update/components/train-card/train-card.component.ts b/src/app/pages/system/update/components/train-card/train-card.component.ts index b66bbc82c4b..69d318e5185 100644 --- a/src/app/pages/system/update/components/train-card/train-card.component.ts +++ b/src/app/pages/system/update/components/train-card/train-card.component.ts @@ -17,9 +17,7 @@ import { Role } from 'app/enums/role.enum'; import { SystemUpdateStatus } from 'app/enums/system-update.enum'; import { filterAsync } from 'app/helpers/operators/filter-async.operator'; import { helptextSystemUpdate } from 'app/helptext/system/update'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { Option } from 'app/interfaces/option.interface'; -import { DialogService } from 'app/modules/dialog/dialog.service'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; @@ -27,6 +25,7 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; import { TrainService } from 'app/pages/system/update/services/train.service'; import { UpdateService } from 'app/pages/system/update/services/update.service'; import { AuthService } from 'app/services/auth/auth.service'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; import { SystemGeneralService } from 'app/services/system-general.service'; @UntilDestroy() @@ -68,13 +67,13 @@ export class TrainCardComponent implements OnInit { constructor( private sysGenService: SystemGeneralService, - private dialogService: DialogService, private translate: TranslateService, private fb: FormBuilder, private authService: AuthService, protected trainService: TrainService, protected updateService: UpdateService, private cdr: ChangeDetectorRef, + private errorHandler: ErrorHandlerService, ) { this.sysGenService.updateRunning.pipe(untilDestroyed(this)).subscribe((isUpdating: string) => { this.isUpdateRunning = isUpdating === 'true'; @@ -131,11 +130,8 @@ export class TrainCardComponent implements OnInit { this.cdr.markForCheck(); }, - error: (error: ApiError) => { - this.dialogService.warn( - error.trace.class, - this.translate.instant('TrueNAS was unable to reach update servers.'), - ); + error: (error: unknown) => { + this.errorHandler.showErrorModal(error); }, }); diff --git a/src/app/pages/system/update/components/update-actions-card/update-actions-card.component.ts b/src/app/pages/system/update/components/update-actions-card/update-actions-card.component.ts index 779a32bac94..04ec29d44b3 100644 --- a/src/app/pages/system/update/components/update-actions-card/update-actions-card.component.ts +++ b/src/app/pages/system/update/components/update-actions-card/update-actions-card.component.ts @@ -22,7 +22,6 @@ import { WINDOW } from 'app/helpers/window.helper'; import { helptextGlobal } from 'app/helptext/global-helptext'; import { helptextSystemUpdate as helptext } from 'app/helptext/system/update'; import { ApiJobMethod } from 'app/interfaces/api/api-job-directory.interface'; -import { ApiError } from 'app/interfaces/api-error.interface'; import { Job } from 'app/interfaces/job.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; @@ -128,7 +127,7 @@ export class UpdateActionsCardComponent implements OnInit { } this.cdr.markForCheck(); }, - error: (err) => { + error: (err: unknown) => { console.error(err); }, }); @@ -239,12 +238,8 @@ export class UpdateActionsCardComponent implements OnInit { this.snackbar.success(this.translate.instant('No updates available.')); } }, - error: (error: ApiError) => { - this.dialogService.error({ - title: this.translate.instant('Error checking for updates.'), - message: error.reason, - backtrace: error.trace?.formatted, - }); + error: (error: unknown) => { + this.errorHandler.showErrorModal(error); }, complete: () => { this.loader.close(); diff --git a/src/app/pages/system/update/services/train.service.ts b/src/app/pages/system/update/services/train.service.ts index 1df3f093fe2..1c4c273c7e9 100644 --- a/src/app/pages/system/update/services/train.service.ts +++ b/src/app/pages/system/update/services/train.service.ts @@ -5,7 +5,7 @@ import { BehaviorSubject, Observable, combineLatest, } from 'rxjs'; import { SystemUpdateOperationType, SystemUpdateStatus } from 'app/enums/system-update.enum'; -import { ApiError } from 'app/interfaces/api-error.interface'; +import { extractApiError } from 'app/helpers/api.helper'; import { SystemUpdateTrain, SystemUpdateTrains } from 'app/interfaces/system-update.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { Package } from 'app/pages/system/update/interfaces/package.interface'; @@ -186,9 +186,10 @@ export class TrainService { } this.updateService.isLoading$.next(false); }, - error: (err: ApiError) => { + error: (err: unknown) => { + const apiError = extractApiError(err); this.updateService.generalUpdateError$.next( - `${err.reason.replace('>', '').replace('<', '')}: ${this.translate.instant('Automatic update check failed. Please check system network settings.')}`, + `${apiError?.reason?.replace('>', '')?.replace('<', '')}: ${this.translate.instant('Automatic update check failed. Please check system network settings.')}`, ); this.updateService.isLoading$.next(false); }, diff --git a/src/app/pages/two-factor-auth/two-factor.component.ts b/src/app/pages/two-factor-auth/two-factor.component.ts index 882af70488f..40e247ca6a2 100644 --- a/src/app/pages/two-factor-auth/two-factor.component.ts +++ b/src/app/pages/two-factor-auth/two-factor.component.ts @@ -12,7 +12,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService, TranslateModule } from '@ngx-translate/core'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { - Observable, forkJoin, of, + Observable, forkJoin, of, EMPTY, } from 'rxjs'; import { catchError, @@ -21,8 +21,6 @@ import { import { UiSearchDirective } from 'app/directives/ui-search.directive'; import { WINDOW } from 'app/helpers/window.helper'; import { helptext2fa } from 'app/helptext/system/2fa'; -import { ApiError } from 'app/interfaces/api-error.interface'; -import { ErrorReport } from 'app/interfaces/error-report.interface'; import { CopyButtonComponent } from 'app/modules/buttons/copy-button/copy-button.component'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { WarningComponent } from 'app/modules/forms/ix-forms/components/warning/warning.component'; @@ -30,6 +28,7 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; import { QrViewerComponent } from 'app/pages/two-factor-auth/qr-viewer/qr-viewer.component'; import { twoFactorElements } from 'app/pages/two-factor-auth/two-factor.elements'; import { AuthService } from 'app/services/auth/auth.service'; +import { ErrorHandlerService } from 'app/services/error-handler.service'; import { ApiService } from 'app/services/websocket/api.service'; @UntilDestroy() @@ -93,6 +92,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { private translate: TranslateService, protected matDialog: MatDialog, private api: ApiService, + private errorHandler: ErrorHandlerService, @Inject(WINDOW) private window: Window, ) {} @@ -129,7 +129,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy { filter(Boolean), switchMap(() => this.renewSecretForUser()), tap(() => this.toggleLoading(false)), - catchError((error: ApiError) => this.handleError(error)), + catchError((error: unknown) => this.handleError(error)), untilDestroyed(this), ).subscribe(); } @@ -141,14 +141,11 @@ export class TwoFactorComponent implements OnInit, OnDestroy { return params.get('secret'); } - private handleError(error: ApiError): Observable { + private handleError(error: unknown): Observable { this.toggleLoading(false); + this.errorHandler.showErrorModal(error); - return this.dialogService.error({ - title: helptext2fa.two_factor.error, - message: error.reason, - backtrace: error.trace?.formatted, - } as ErrorReport); + return EMPTY; } private renewSecretForUser(): Observable { diff --git a/src/app/pages/virtualization/components/all-instances/all-instances.component.html b/src/app/pages/virtualization/components/all-instances/all-instances.component.html index 391454568b1..a36fe8031e2 100644 --- a/src/app/pages/virtualization/components/all-instances/all-instances.component.html +++ b/src/app/pages/virtualization/components/all-instances/all-instances.component.html @@ -9,11 +9,11 @@ - + + {{ 'Details for' | translate }} {{ selectedInstance()?.name }} diff --git a/src/app/pages/virtualization/components/all-instances/instance-details/instance-disks/instance-disk-form/instance-disk-form.component.ts b/src/app/pages/virtualization/components/all-instances/instance-details/instance-disks/instance-disk-form/instance-disk-form.component.ts index e33c307107d..d02352242f4 100644 --- a/src/app/pages/virtualization/components/all-instances/instance-details/instance-disks/instance-disk-form/instance-disk-form.component.ts +++ b/src/app/pages/virtualization/components/all-instances/instance-details/instance-disks/instance-disk-form/instance-disk-form.component.ts @@ -95,7 +95,7 @@ export class InstanceDiskFormComponent implements OnInit { }); this.isLoading.set(false); }, - error: (error) => { + error: (error: unknown) => { this.errorHandler.handleValidationErrors(error, this.form); this.isLoading.set(false); }, diff --git a/src/app/pages/virtualization/components/all-instances/instance-details/instance-general-info/instance-edit-form/instance-edit-form.component.ts b/src/app/pages/virtualization/components/all-instances/instance-details/instance-general-info/instance-edit-form/instance-edit-form.component.ts index 2a97bf253b2..0b8934c2d90 100644 --- a/src/app/pages/virtualization/components/all-instances/instance-details/instance-general-info/instance-edit-form/instance-edit-form.component.ts +++ b/src/app/pages/virtualization/components/all-instances/instance-details/instance-general-info/instance-edit-form/instance-edit-form.component.ts @@ -101,7 +101,7 @@ export class InstanceEditFormComponent { this.snackbar.success(this.translate.instant('Instance updated')); this.slideInRef.close(true); }, - error: (error) => { + error: (error: unknown) => { this.formErrorHandler.handleValidationErrors(error, this.form); }, }); diff --git a/src/app/pages/virtualization/components/all-instances/instance-details/instance-proxies/instance-proxy-form/instance-proxy-form.component.ts b/src/app/pages/virtualization/components/all-instances/instance-details/instance-proxies/instance-proxy-form/instance-proxy-form.component.ts index 0dec62eb166..752ce320d0d 100644 --- a/src/app/pages/virtualization/components/all-instances/instance-details/instance-proxies/instance-proxy-form/instance-proxy-form.component.ts +++ b/src/app/pages/virtualization/components/all-instances/instance-details/instance-proxies/instance-proxy-form/instance-proxy-form.component.ts @@ -101,7 +101,7 @@ export class InstanceProxyFormComponent implements OnInit { }); this.isLoading.set(false); }, - error: (error) => { + error: (error: unknown) => { this.errorHandler.handleValidationErrors(error, this.form); this.isLoading.set(false); }, diff --git a/src/app/pages/virtualization/components/all-instances/instance-list/instance-list.component.html b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list.component.html index c163153394c..c0e5c8d71f9 100644 --- a/src/app/pages/virtualization/components/all-instances/instance-list/instance-list.component.html +++ b/src/app/pages/virtualization/components/all-instances/instance-list/instance-list.component.html @@ -10,12 +10,10 @@

    {{ 'Instances' | translate }}