]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Introduce NVMe/TCP navigation
authorAfreen <afreen23.git@gmail.com>
Fri, 31 May 2024 07:54:27 +0000 (13:24 +0530)
committerAfreen Misbah <afreen23.git@gmail.com>
Mon, 15 Jul 2024 15:22:42 +0000 (20:52 +0530)
Fixes https://tracker.ceph.com/issues/66346

- adds NVMe/TCP tab under Block nav
- adds overview page for NVMe/TCP nav
- overview page lists gateways
- add default error page when no nvmeof service running
- added unit tests
- fixes service page e2e test

Signed-off-by: Afreen <afreen23.git@gmail.com>
(cherry picked from commit 442346f0efbb5d7d3af3ebdf847586bbe3f93f6d)

Conflicts:
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html

18 files changed:
src/pybind/mgr/dashboard/controllers/nvmeof.py
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/breadcrumbs.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts

index 3d9e3378702714cb331b08a481cd690789b113dc..84d7a37952e7265812ab052ffcd56e7ea26bf446 100644 (file)
@@ -1,16 +1,27 @@
 # -*- coding: utf-8 -*-
-from typing import Optional
+import logging
+from typing import Any, Dict, Optional
 
+from .. import mgr
 from ..model import nvmeof as model
 from ..security import Scope
+from ..services.orchestrator import OrchClient
 from ..tools import str_to_bool
-from . import APIDoc, APIRouter, Endpoint, EndpointDoc, Param, ReadPermission, RESTController
+from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, Param, \
+    ReadPermission, RESTController, UIRouter
+
+logger = logging.getLogger(__name__)
+
+NVME_SCHEMA = {
+    "available": (bool, "Is NVMe/TCP available?"),
+    "message": (str, "Descriptions")
+}
 
 try:
     from ..services.nvmeof_client import NVMeoFClient, empty_response, \
         handle_nvmeof_error, map_collection, map_model
-except ImportError:
-    pass
+except ImportError as e:
+    logger.error("Failed to import NVMeoFClient and related components: %s", e)
 else:
     @APIRouter("/nvmeof/gateway", Scope.NVME_OF)
     @APIDoc("NVMe-oF Gateway Management API", "NVMe-oF Gateway")
@@ -380,3 +391,24 @@ else:
             return NVMeoFClient().stub.list_connections(
                 NVMeoFClient.pb2.list_connections_req(subsystem=nqn)
             )
+
+
+@UIRouter('/nvmeof', Scope.NVME_OF)
+@APIDoc("NVMe/TCP Management API", "NVMe/TCP")
+class NVMeoFStatus(BaseController):
+    @Endpoint()
+    @ReadPermission
+    @EndpointDoc("Display NVMe/TCP service Status",
+                 responses={200: NVME_SCHEMA})
+    def status(self) -> dict:
+        status: Dict[str, Any] = {'available': True, 'message': None}
+        orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator')
+        if orch_backend == 'cephadm':
+            orch = OrchClient.instance()
+            orch_status = orch.status()
+            if not orch_status['available']:
+                return status
+            if not orch.services.list_daemons(daemon_type='nvmeof'):
+                status["available"] = False
+                status["message"] = 'Create an NVMe/TCP service to get started.'
+        return status
index f861ad183a2ed0d7e946dd1c52ac23bdcf764561..aa5ef25863439730c6349e6475da42aebfc873e0 100644 (file)
@@ -177,6 +177,11 @@ const routes: Routes = [
             component: ServiceFormComponent,
             outlet: 'modal'
           },
+          {
+            path: `${URLVerbs.CREATE}/:type`,
+            component: ServiceFormComponent,
+            outlet: 'modal'
+          },
           {
             path: `${URLVerbs.EDIT}/:type/:name`,
             component: ServiceFormComponent,
index b9995ac029de96cd6d293b258f6931b217449a42..30a8d21d2c3f32b498f91bf0d2a742dc4c816903 100644 (file)
@@ -38,6 +38,7 @@ import { RbdTrashListComponent } from './rbd-trash-list/rbd-trash-list.component
 import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-move-modal.component';
 import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal/rbd-trash-purge-modal.component';
 import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-trash-restore-modal.component';
+import { NvmeofGatewayComponent } from './nvmeof-gateway/nvmeof-gateway.component';
 
 @NgModule({
   imports: [
@@ -77,7 +78,8 @@ import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-tra
     RbdConfigurationListComponent,
     RbdConfigurationFormComponent,
     RbdTabsComponent,
-    RbdPerformanceComponent
+    RbdPerformanceComponent,
+    NvmeofGatewayComponent
   ],
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
 })
@@ -198,6 +200,29 @@ const routes: Routes = [
         ]
       }
     ]
+  },
+  // NVMe/TCP
+  {
+    path: 'nvmeof',
+    canActivate: [ModuleStatusGuardService],
+    data: {
+      breadcrumbs: true,
+      text: 'NVMe/TCP',
+      path: 'nvmeof',
+      disableSplit: true,
+      moduleStatusGuardConfig: {
+        uiApiPath: 'nvmeof',
+        redirectTo: 'error',
+        header: $localize`NVMe/TCP Gateway not configured`,
+        button_name: $localize`Configure NVMe/TCP`,
+        button_route: ['/services', { outlets: { modal: ['create', 'nvmeof'] } }],
+        uiConfig: false
+      }
+    },
+    children: [
+      { path: '', redirectTo: 'gateways', pathMatch: 'full' },
+      { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } }
+    ]
   }
 ];
 
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html
new file mode 100644 (file)
index 0000000..3d27a77
--- /dev/null
@@ -0,0 +1,22 @@
+<ul class="nav nav-tabs">
+  <li class="nav-item">
+    <a class="nav-link"
+       routerLink="/block/nvmeof/gateways"
+       routerLinkActive="active"
+       ariaCurrentWhenActive="page"
+       i18n>Gateways</a>
+  </li>
+</ul>
+
+<legend i18n>
+  Gateways
+  <cd-help-text>
+    The NVMe-oF gateway integrates Ceph with the NVMe over TCP (NVMe/TCP) protocol to provide an NVMe/TCP target that exports RADOS Block Device (RBD) images.
+  </cd-help-text>
+</legend>
+<div>
+  <cd-table [data]="gateways"
+            (fetchData)="getGateways()"
+            [columns]="gatewayColumns">
+  </cd-table>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.spec.ts
new file mode 100644 (file)
index 0000000..53187cd
--- /dev/null
@@ -0,0 +1,53 @@
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { NvmeofGatewayComponent } from './nvmeof-gateway.component';
+import { NvmeofService } from '../../../shared/api/nvmeof.service';
+import { HttpClientModule } from '@angular/common/http';
+import { SharedModule } from '~/app/shared/shared.module';
+
+const mockGateways = [
+  {
+    cli_version: '',
+    version: '1.2.5',
+    name: 'client.nvmeof.rbd.ceph-node-01.jnmnwa',
+    group: '',
+    addr: '192.168.100.101',
+    port: '5500',
+    load_balancing_group: 1,
+    spdk_version: '24.01'
+  }
+];
+
+class MockNvmeOfService {
+  listGateways() {
+    return of(mockGateways);
+  }
+}
+
+describe('NvmeofGatewayComponent', () => {
+  let component: NvmeofGatewayComponent;
+  let fixture: ComponentFixture<NvmeofGatewayComponent>;
+
+  beforeEach(fakeAsync(() => {
+    TestBed.configureTestingModule({
+      declarations: [NvmeofGatewayComponent],
+      imports: [HttpClientModule, SharedModule],
+      providers: [{ provide: NvmeofService, useClass: MockNvmeOfService }]
+    }).compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NvmeofGatewayComponent);
+    component = fixture.componentInstance;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should retrieve gateways', fakeAsync(() => {
+    component.getGateways();
+    tick();
+    expect(component.gateways).toEqual(mockGateways);
+  }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts
new file mode 100644 (file)
index 0000000..7d5b3fb
--- /dev/null
@@ -0,0 +1,47 @@
+import { Component, OnInit } from '@angular/core';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { NvmeofGateway } from '~/app/shared/models/nvmeof';
+
+import { NvmeofService } from '../../../shared/api/nvmeof.service';
+
+@Component({
+  selector: 'cd-nvmeof-gateway',
+  templateUrl: './nvmeof-gateway.component.html',
+  styleUrls: ['./nvmeof-gateway.component.scss']
+})
+export class NvmeofGatewayComponent extends ListWithDetails implements OnInit {
+  gateways: NvmeofGateway[] = [];
+  gatewayColumns: any;
+  selection = new CdTableSelection();
+
+  constructor(private nvmeofService: NvmeofService, public actionLabels: ActionLabelsI18n) {
+    super();
+  }
+
+  ngOnInit() {
+    this.gatewayColumns = [
+      {
+        name: $localize`Name`,
+        prop: 'name'
+      },
+      {
+        name: $localize`Address`,
+        prop: 'addr'
+      },
+      {
+        name: $localize`Port`,
+        prop: 'port'
+      }
+    ];
+  }
+
+  getGateways() {
+    this.nvmeofService.listGateways().subscribe((gateways: NvmeofGateway[] | NvmeofGateway) => {
+      if (Array.isArray(gateways)) this.gateways = gateways;
+      else this.gateways = [gateways];
+    });
+  }
+}
index c659d76b97e1219eb4641a88c3c3957747fb00f5..7a439e23dfe70ab4c1547b1a0c532f1245a1a885 100644 (file)
             <select id="placement"
                     class="form-select"
                     formControlName="placement"
-                    (change)="onPlacementChange($event.target.value)">
+                    (change)="onServiceTypeChange($event.target.value)">
               <option i18n
                       value="hosts">Hosts</option>
               <option i18n
index 436dd5364bb6f35a8b30da3e613f07527f8e1562..da7fca61bc24bcb29a85b24af52a3a4eb336d22f 100644 (file)
@@ -424,10 +424,15 @@ export class ServiceFormComponent extends CdForm implements OnInit {
     });
   }
 
-  ngOnInit(): void {
-    this.action = this.actionLabels.CREATE;
+  resolveRoute() {
     if (this.router.url.includes('services/(modal:create')) {
       this.pageURL = 'services';
+      this.route.params.subscribe((params: { type: string }) => {
+        if (params?.type) {
+          this.serviceType = params.type;
+          this.serviceForm.get('service_type').setValue(this.serviceType);
+        }
+      });
     } else if (this.router.url.includes('services/(modal:edit')) {
       this.editing = true;
       this.pageURL = 'services';
@@ -436,6 +441,11 @@ export class ServiceFormComponent extends CdForm implements OnInit {
         this.serviceType = params.type;
       });
     }
+  }
+
+  ngOnInit(): void {
+    this.action = this.actionLabels.CREATE;
+    this.resolveRoute();
 
     this.cephServiceService
       .list(new HttpParams({ fromObject: { limit: -1, offset: 0 } }))
@@ -471,6 +481,9 @@ export class ServiceFormComponent extends CdForm implements OnInit {
     this.poolService.getList().subscribe((resp: Pool[]) => {
       this.pools = resp;
       this.rbdPools = this.pools.filter(this.rbdService.isRBDPool);
+      if (!this.editing && this.serviceType) {
+        this.onServiceTypeChange(this.serviceType);
+      }
     });
 
     if (this.editing) {
@@ -670,6 +683,8 @@ export class ServiceFormComponent extends CdForm implements OnInit {
       case 'smb':
         this.serviceForm.get('count').setValue(1);
         break;
+      default:
+        this.serviceForm.get('count').setValue(null);
     }
   }
 
@@ -759,7 +774,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
   }
 
   setNvmeofServiceId(): void {
-    const defaultRbdPool: string = this.rbdPools.find((p: Pool) => p.pool_name === 'rbd')
+    const defaultRbdPool: string = this.rbdPools?.find((p: Pool) => p.pool_name === 'rbd')
       ?.pool_name;
     if (defaultRbdPool) {
       this.serviceForm.get('pool').setValue(defaultRbdPool);
index f10c6a56d854927789c386c66a6365324b6cbf9a..b92b2ae497eb3756185d8d7fa5844b1708e6376f 100644 (file)
@@ -79,7 +79,7 @@ describe('BreadcrumbsComponent', () => {
     tick();
     expect(component.crumbs).toEqual([
       { path: null, text: 'Cluster' },
-      { path: '/hosts', text: 'Hosts' }
+      { path: '/hosts', text: 'Hosts', disableSplit: false }
     ]);
   }));
 
@@ -125,9 +125,9 @@ describe('BreadcrumbsComponent', () => {
     });
     tick();
     expect(component.crumbs).toEqual([
-      { path: null, text: 'Block' },
-      { path: '/block/rbd', text: 'Images' },
-      { path: '/block/rbd/add', text: 'Add' }
+      { path: null, text: 'Block', disableSplit: false },
+      { path: '/block/rbd', text: 'Images', disableSplit: false },
+      { path: '/block/rbd/add', text: 'Add', disableSplit: false }
     ]);
   }));
 
index 860b89ec90b4c877900c338dff0a1f6dbb944cd8..82d69fbf5d1f5fe4c5d36f97e1605c4acb943b78 100644 (file)
@@ -115,7 +115,7 @@ export class BreadcrumbsComponent implements OnDestroy {
     const result: IBreadcrumb[] = [];
     breadcrumbs.forEach((element) => {
       const split = element.text.split('/');
-      if (split.length > 1) {
+      if (!element.disableSplit && split.length > 1) {
         element.text = split[split.length - 1];
         for (let i = 0; i < split.length - 1; i++) {
           result.push({ text: split[i], path: null });
index 6bec8139009131122425987cd549dfe1e7bb1418..0f0ce6fa213ead65754b100c53b42df80752d4b3 100644 (file)
             <a i18n
                routerLink="/block/iscsi">iSCSI</a>
           </li>
+
+          <li routerLinkActive="active"
+              class="tc_submenuitem">
+            <a i18n
+               routerLink="/block/nvmeof">NVMe/TCP</a>
+          </li>
         </ul>
       </li>
 
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts
new file mode 100644 (file)
index 0000000..dd6aba7
--- /dev/null
@@ -0,0 +1,33 @@
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { NvmeofService } from '../../shared/api/nvmeof.service';
+
+describe('NvmeofService', () => {
+  let service: NvmeofService;
+  let httpTesting: HttpTestingController;
+
+  configureTestBed({
+    providers: [NvmeofService],
+    imports: [HttpClientTestingModule]
+  });
+
+  beforeEach(() => {
+    service = TestBed.inject(NvmeofService);
+    httpTesting = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpTesting.verify();
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should call listGateways', () => {
+    service.listGateways().subscribe();
+    const req = httpTesting.expectOne('api/nvmeof/gateway');
+    expect(req.request.method).toBe('GET');
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts
new file mode 100644 (file)
index 0000000..b1d4bac
--- /dev/null
@@ -0,0 +1,15 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+const BASE_URL = 'api/nvmeof';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class NvmeofService {
+  constructor(private http: HttpClient) {}
+
+  listGateways() {
+    return this.http.get(`${BASE_URL}/gateway`);
+  }
+}
index 10e799929da2be5c23db19c48410c5951f3264de..9f0fc49786db5853f4afdfe2b777fb040a0daa64 100644 (file)
@@ -32,13 +32,14 @@ export class BreadcrumbsResolver implements Resolve<IBreadcrumb[]> {
   ): Observable<IBreadcrumb[]> | Promise<IBreadcrumb[]> | IBreadcrumb[] {
     const data = route.routeConfig.data;
     const path = data.path === null ? null : this.getFullPath(route);
+    const disableSplit = data.disableSplit || false;
 
     const text =
       typeof data.breadcrumbs === 'string'
         ? data.breadcrumbs
         : data.breadcrumbs.text || data.text || path;
 
-    const crumbs: IBreadcrumb[] = [{ text: text, path: path }];
+    const crumbs: IBreadcrumb[] = [{ text: text, path: path, disableSplit: disableSplit }];
 
     return of(crumbs);
   }
@@ -56,4 +57,5 @@ export class BreadcrumbsResolver implements Resolve<IBreadcrumb[]> {
 export interface IBreadcrumb {
   text: string;
   path: string;
+  disableSplit?: boolean;
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts
new file mode 100644 (file)
index 0000000..f2a92ea
--- /dev/null
@@ -0,0 +1,10 @@
+export interface NvmeofGateway {
+  cli_version: string;
+  version: string;
+  name: string;
+  group: string;
+  addr: string;
+  port: string;
+  load_balancing_group: string;
+  spdk_version: string;
+}
index fb2c90469ccc2cb4e9bb7df09e15e730b4c12223..213fb416ea5fbb9986b65a9f1e72d99f1ea7ccb8 100644 (file)
@@ -8,6 +8,7 @@ describe('cd-notification classes', () => {
       grafana: { create: false, delete: false, read: false, update: false },
       hosts: { create: false, delete: false, read: false, update: false },
       iscsi: { create: false, delete: false, read: false, update: false },
+      nvmeof: { create: false, delete: false, read: false, update: false },
       log: { create: false, delete: false, read: false, update: false },
       manager: { create: false, delete: false, read: false, update: false },
       monitor: { create: false, delete: false, read: false, update: false },
@@ -29,6 +30,7 @@ describe('cd-notification classes', () => {
       grafana: ['create', 'read', 'update', 'delete'],
       hosts: ['create', 'read', 'update', 'delete'],
       iscsi: ['create', 'read', 'update', 'delete'],
+      'nvme-of': ['create', 'read', 'update', 'delete'],
       log: ['create', 'read', 'update', 'delete'],
       manager: ['create', 'read', 'update', 'delete'],
       monitor: ['create', 'read', 'update', 'delete'],
@@ -46,6 +48,7 @@ describe('cd-notification classes', () => {
       grafana: { create: true, delete: true, read: true, update: true },
       hosts: { create: true, delete: true, read: true, update: true },
       iscsi: { create: true, delete: true, read: true, update: true },
+      nvmeof: { create: true, delete: true, read: true, update: true },
       log: { create: true, delete: true, read: true, update: true },
       manager: { create: true, delete: true, read: true, update: true },
       monitor: { create: true, delete: true, read: true, update: true },
index 3f2c87ed1a0fe109602775e1ff4f8c920674e7f6..5e9fe4aae47a629aa0444dc67383042984824805 100644 (file)
@@ -19,6 +19,7 @@ export class Permissions {
   monitor: Permission;
   rbdImage: Permission;
   iscsi: Permission;
+  nvmeof: Permission;
   rbdMirroring: Permission;
   rgw: Permission;
   cephfs: Permission;
@@ -37,6 +38,7 @@ export class Permissions {
     this.monitor = new Permission(serverPermissions['monitor']);
     this.rbdImage = new Permission(serverPermissions['rbd-image']);
     this.iscsi = new Permission(serverPermissions['iscsi']);
+    this.nvmeof = new Permission(serverPermissions['nvme-of']);
     this.rbdMirroring = new Permission(serverPermissions['rbd-mirroring']);
     this.rgw = new Permission(serverPermissions['rgw']);
     this.cephfs = new Permission(serverPermissions['cephfs']);