]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/blob
df6ae3a250f6bbea0047cb20d18e58f75e1c02a3
[ceph-ci.git] /
1 import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
2 import { Validators } from '@angular/forms';
3
4 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
5 import { I18n } from '@ngx-translate/i18n-polyfill';
6 import {
7   ITreeOptions,
8   TreeComponent,
9   TreeModel,
10   TreeNode,
11   TREE_ACTIONS
12 } from 'angular-tree-component';
13 import * as _ from 'lodash';
14 import * as moment from 'moment';
15
16 import { CephfsService } from '../../../shared/api/cephfs.service';
17 import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
18 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
19 import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component';
20 import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
21 import { Icons } from '../../../shared/enum/icons.enum';
22 import { NotificationType } from '../../../shared/enum/notification-type.enum';
23 import { CdValidators } from '../../../shared/forms/cd-validators';
24 import { CdFormModalFieldConfig } from '../../../shared/models/cd-form-modal-field-config';
25 import { CdTableAction } from '../../../shared/models/cd-table-action';
26 import { CdTableColumn } from '../../../shared/models/cd-table-column';
27 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
28 import {
29   CephfsDir,
30   CephfsQuotas,
31   CephfsSnapshot
32 } from '../../../shared/models/cephfs-directory-models';
33 import { Permission } from '../../../shared/models/permissions';
34 import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
35 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
36 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
37 import { ModalService } from '../../../shared/services/modal.service';
38 import { NotificationService } from '../../../shared/services/notification.service';
39
40 class QuotaSetting {
41   row: {
42     // Used in quota table
43     name: string;
44     value: number | string;
45     originPath: string;
46   };
47   quotaKey: string;
48   dirValue: number;
49   nextTreeMaximum: {
50     value: number;
51     path: string;
52   };
53 }
54
55 @Component({
56   selector: 'cd-cephfs-directories',
57   templateUrl: './cephfs-directories.component.html',
58   styleUrls: ['./cephfs-directories.component.scss']
59 })
60 export class CephfsDirectoriesComponent implements OnInit, OnChanges {
61   @ViewChild(TreeComponent)
62   treeComponent: TreeComponent;
63   @ViewChild('origin', { static: true })
64   originTmpl: TemplateRef<any>;
65
66   @Input()
67   id: number;
68
69   private modalRef: NgbModalRef;
70   private dirs: CephfsDir[];
71   private nodeIds: { [path: string]: CephfsDir };
72   private requestedPaths: string[];
73   private loadingTimeout: any;
74
75   icons = Icons;
76   loadingIndicator = false;
77   loading = {};
78   treeOptions: ITreeOptions = {
79     useVirtualScroll: true,
80     getChildren: (node: TreeNode): Promise<any[]> => {
81       return this.updateDirectory(node.id);
82     },
83     actionMapping: {
84       mouse: {
85         click: this.selectAndShowNode.bind(this),
86         expanderClick: this.selectAndShowNode.bind(this)
87       }
88     }
89   };
90
91   permission: Permission;
92   selectedDir: CephfsDir;
93   settings: QuotaSetting[];
94   quota: {
95     columns: CdTableColumn[];
96     selection: CdTableSelection;
97     tableActions: CdTableAction[];
98     updateSelection: Function;
99   };
100   snapshot: {
101     columns: CdTableColumn[];
102     selection: CdTableSelection;
103     tableActions: CdTableAction[];
104     updateSelection: Function;
105   };
106   nodes: any[];
107
108   constructor(
109     private authStorageService: AuthStorageService,
110     private modalService: ModalService,
111     private cephfsService: CephfsService,
112     private cdDatePipe: CdDatePipe,
113     private i18n: I18n,
114     private actionLabels: ActionLabelsI18n,
115     private notificationService: NotificationService,
116     private dimlessBinaryPipe: DimlessBinaryPipe
117   ) {}
118
119   private selectAndShowNode(tree: TreeModel, node: TreeNode, $event: any) {
120     TREE_ACTIONS.TOGGLE_EXPANDED(tree, node, $event);
121     this.selectNode(node);
122   }
123
124   private selectNode(node: TreeNode) {
125     TREE_ACTIONS.TOGGLE_ACTIVE(undefined, node, undefined);
126     this.selectedDir = this.getDirectory(node);
127     if (node.id === '/') {
128       return;
129     }
130     this.setSettings(node);
131   }
132
133   ngOnInit() {
134     this.permission = this.authStorageService.getPermissions().cephfs;
135     this.setUpQuotaTable();
136     this.setUpSnapshotTable();
137   }
138
139   private setUpQuotaTable() {
140     this.quota = {
141       columns: [
142         {
143           prop: 'row.name',
144           name: this.i18n('Name'),
145           flexGrow: 1
146         },
147         {
148           prop: 'row.value',
149           name: this.i18n('Value'),
150           sortable: false,
151           flexGrow: 1
152         },
153         {
154           prop: 'row.originPath',
155           name: this.i18n('Origin'),
156           sortable: false,
157           cellTemplate: this.originTmpl,
158           flexGrow: 1
159         }
160       ],
161       selection: new CdTableSelection(),
162       updateSelection: (selection: CdTableSelection) => {
163         this.quota.selection = selection;
164       },
165       tableActions: [
166         {
167           name: this.actionLabels.SET,
168           icon: Icons.edit,
169           permission: 'update',
170           visible: (selection) =>
171             !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
172           click: () => this.updateQuotaModal()
173         },
174         {
175           name: this.actionLabels.UPDATE,
176           icon: Icons.edit,
177           permission: 'update',
178           visible: (selection) => selection.first() && selection.first().dirValue > 0,
179           click: () => this.updateQuotaModal()
180         },
181         {
182           name: this.actionLabels.UNSET,
183           icon: Icons.destroy,
184           permission: 'update',
185           disable: (selection) =>
186             !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
187           click: () => this.unsetQuotaModal()
188         }
189       ]
190     };
191   }
192
193   private setUpSnapshotTable() {
194     this.snapshot = {
195       columns: [
196         {
197           prop: 'name',
198           name: this.i18n('Name'),
199           flexGrow: 1
200         },
201         {
202           prop: 'path',
203           name: this.i18n('Path'),
204           isHidden: true,
205           flexGrow: 2
206         },
207         {
208           prop: 'created',
209           name: this.i18n('Created'),
210           flexGrow: 1,
211           pipe: this.cdDatePipe
212         }
213       ],
214       selection: new CdTableSelection(),
215       updateSelection: (selection: CdTableSelection) => {
216         this.snapshot.selection = selection;
217       },
218       tableActions: [
219         {
220           name: this.actionLabels.CREATE,
221           icon: Icons.add,
222           permission: 'create',
223           canBePrimary: (selection) => !selection.hasSelection,
224           click: () => this.createSnapshot()
225         },
226         {
227           name: this.actionLabels.DELETE,
228           icon: Icons.destroy,
229           permission: 'delete',
230           click: () => this.deleteSnapshotModal(),
231           canBePrimary: (selection) => selection.hasSelection,
232           disable: (selection) => !selection.hasSelection
233         }
234       ]
235     };
236   }
237
238   ngOnChanges() {
239     this.selectedDir = undefined;
240     this.dirs = [];
241     this.requestedPaths = [];
242     this.nodeIds = {};
243     if (this.id) {
244       this.setRootNode();
245       this.firstCall();
246     }
247   }
248
249   private setRootNode() {
250     this.nodes = [
251       {
252         name: '/',
253         id: '/',
254         isExpanded: true
255       }
256     ];
257   }
258
259   private firstCall() {
260     const path = '/';
261     setTimeout(() => {
262       this.getNode(path).loadNodeChildren();
263     }, 10);
264   }
265
266   updateDirectory(path: string): Promise<any[]> {
267     this.unsetLoadingIndicator();
268     if (!this.requestedPaths.includes(path)) {
269       this.requestedPaths.push(path);
270     } else if (this.loading[path] === true) {
271       return undefined; // Path is currently fetched.
272     }
273     return new Promise((resolve) => {
274       this.setLoadingIndicator(path, true);
275       this.cephfsService.lsDir(this.id, path).subscribe((dirs) => {
276         this.updateTreeStructure(dirs);
277         this.updateQuotaTable();
278         this.updateTree();
279         resolve(this.getChildren(path));
280         this.setLoadingIndicator(path, false);
281       });
282     });
283   }
284
285   private setLoadingIndicator(path: string, loading: boolean) {
286     this.loading[path] = loading;
287     this.unsetLoadingIndicator();
288   }
289
290   private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
291     return tree.filter((d) => d.parent === path);
292   }
293
294   private getChildren(path: string): any[] {
295     const subTree = this.getSubTree(path);
296     return _.sortBy(this.getSubDirectories(path), 'path').map((dir) =>
297       this.createNode(dir, subTree)
298     );
299   }
300
301   private createNode(dir: CephfsDir, subTree?: CephfsDir[]): any {
302     this.nodeIds[dir.path] = dir;
303     if (!subTree) {
304       this.getSubTree(dir.parent);
305     }
306     return {
307       name: dir.name,
308       id: dir.path,
309       hasChildren: this.getSubDirectories(dir.path, subTree).length > 0
310     };
311   }
312
313   private getSubTree(path: string): CephfsDir[] {
314     return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
315   }
316
317   private setSettings(node: TreeNode) {
318     const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
319       value ? (fn ? fn(value) : value) : '';
320
321     this.settings = [
322       this.getQuota(node, 'max_files', readable),
323       this.getQuota(node, 'max_bytes', (value) =>
324         readable(value, (v) => this.dimlessBinaryPipe.transform(v))
325       )
326     ];
327   }
328
329   private getQuota(
330     tree: TreeNode,
331     quotaKey: string,
332     valueConvertFn: (number: number) => number | string
333   ): QuotaSetting {
334     // Get current maximum
335     const currentPath = tree.id;
336     tree = this.getOrigin(tree, quotaKey);
337     const dir = this.getDirectory(tree);
338     const value = dir.quotas[quotaKey];
339     // Get next tree maximum
340     // => The value that isn't changeable through a change of the current directories quota value
341     let nextMaxValue = value;
342     let nextMaxPath = dir.path;
343     if (tree.id === currentPath) {
344       if (tree.parent.id === '/') {
345         // The value will never inherit any other value, so it has no maximum.
346         nextMaxValue = 0;
347       } else {
348         const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
349         nextMaxValue = nextMaxDir.quotas[quotaKey];
350         nextMaxPath = nextMaxDir.path;
351       }
352     }
353     return {
354       row: {
355         name: quotaKey === 'max_bytes' ? this.i18n('Max size') : this.i18n('Max files'),
356         value: valueConvertFn(value),
357         originPath: value ? dir.path : ''
358       },
359       quotaKey,
360       dirValue: this.nodeIds[currentPath].quotas[quotaKey],
361       nextTreeMaximum: {
362         value: nextMaxValue,
363         path: nextMaxValue ? nextMaxPath : ''
364       }
365     };
366   }
367
368   /**
369    * Get the node where the quota limit originates from in the current node
370    *
371    * Example as it's a recursive method:
372    *
373    * |  Path + Value | Call depth |       useOrigin?      | Output |
374    * |:-------------:|:----------:|:---------------------:|:------:|
375    * | /a/b/c/d (15) |     1st    | 2nd (5) < 15 => false |  /a/b  |
376    * | /a/b/c (20)   |     2nd    | 3rd (5) < 20 => false |  /a/b  |
377    * | /a/b (5)      |     3rd    |  4th (10) < 5 => true |  /a/b  |
378    * | /a (10)       |     4th    |       10 => true      |   /a   |
379    *
380    */
381   private getOrigin(tree: TreeNode, quotaSetting: string): TreeNode {
382     if (tree.parent && tree.parent.id !== '/') {
383       const current = this.getQuotaFromTree(tree, quotaSetting);
384
385       // Get the next used quota and node above the current one (until it hits the root directory)
386       const originTree = this.getOrigin(tree.parent, quotaSetting);
387       const inherited = this.getQuotaFromTree(originTree, quotaSetting);
388
389       // Select if the current quota is in use or the above
390       const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
391       return useOrigin ? originTree : tree;
392     }
393     return tree;
394   }
395
396   private getQuotaFromTree(tree: TreeNode, quotaSetting: string): number {
397     return this.getDirectory(tree).quotas[quotaSetting];
398   }
399
400   private getDirectory(node: TreeNode): CephfsDir {
401     const path = node.id as string;
402     return this.nodeIds[path];
403   }
404
405   selectOrigin(path: string) {
406     this.selectNode(this.getNode(path));
407   }
408
409   private getNode(path: string): TreeNode {
410     return this.treeComponent.treeModel.getNodeById(path);
411   }
412
413   updateQuotaModal() {
414     const path = this.selectedDir.path;
415     const selection: QuotaSetting = this.quota.selection.first();
416     const nextMax = selection.nextTreeMaximum;
417     const key = selection.quotaKey;
418     const value = selection.dirValue;
419     this.modalService.show(FormModalComponent, {
420       titleText: this.getModalQuotaTitle(
421         value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
422         path
423       ),
424       message: nextMax.value
425         ? this.i18n('The inherited {{quotaValue}} is the maximum value to be used.', {
426             quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
427           })
428         : undefined,
429       fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
430       submitButtonText: this.i18n('Save'),
431       onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
432     });
433   }
434
435   private getModalQuotaTitle(action: string, path: string): string {
436     return this.i18n(`{{action}} CephFS {{quotaName}} quota for '{{path}}'`, {
437       action,
438       quotaName: this.getQuotaName(),
439       path
440     });
441   }
442
443   private getQuotaName(): string {
444     return this.isBytesQuotaSelected() ? this.i18n('size') : this.i18n('files');
445   }
446
447   private isBytesQuotaSelected(): boolean {
448     return this.quota.selection.first().quotaKey === 'max_bytes';
449   }
450
451   private getQuotaValueFromPathMsg(value: number, path: string): string {
452     return this.i18n(`{{quotaName}} quota {{value}} from '{{path}}'`, {
453       value: this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value,
454       quotaName: this.getQuotaName(),
455       path
456     });
457   }
458
459   private getQuotaFormField(
460     label: string,
461     name: string,
462     value: number,
463     maxValue: number
464   ): CdFormModalFieldConfig {
465     const isBinary = name === 'max_bytes';
466     const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
467     if (maxValue) {
468       formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
469     }
470     const field: CdFormModalFieldConfig = {
471       type: isBinary ? 'binary' : 'number',
472       label,
473       name,
474       value,
475       validators: formValidators,
476       required: true
477     };
478     if (!isBinary) {
479       field.errors = {
480         min: this.i18n(`Value has to be at least {{value}} or more`, { value: 0 }),
481         max: this.i18n(`Value has to be at most {{value}} or less`, { value: maxValue })
482       };
483     }
484     return field;
485   }
486
487   private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
488     const path = this.selectedDir.path;
489     const key = this.quota.selection.first().quotaKey;
490     const action =
491       this.selectedDir.quotas[key] === 0
492         ? this.actionLabels.SET
493         : values[key] === 0
494         ? this.actionLabels.UNSET
495         : this.i18n('Updated');
496     this.cephfsService.updateQuota(this.id, path, values).subscribe(() => {
497       if (onSuccess) {
498         onSuccess();
499       }
500       this.notificationService.show(
501         NotificationType.success,
502         this.getModalQuotaTitle(action, path)
503       );
504       this.forceDirRefresh();
505     });
506   }
507
508   unsetQuotaModal() {
509     const path = this.selectedDir.path;
510     const selection: QuotaSetting = this.quota.selection.first();
511     const key = selection.quotaKey;
512     const nextMax = selection.nextTreeMaximum;
513     const dirValue = selection.dirValue;
514
515     this.modalRef = this.modalService.show(ConfirmationModalComponent, {
516       titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
517       buttonText: this.actionLabels.UNSET,
518       description: this.i18n(`{{action}} {{quotaValue}} {{conclusion}}.`, {
519         action: this.actionLabels.UNSET,
520         quotaValue: this.getQuotaValueFromPathMsg(dirValue, path),
521         conclusion:
522           nextMax.value > 0
523             ? nextMax.value > dirValue
524               ? this.i18n('in order to inherit {{quotaValue}}', {
525                   quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
526                 })
527               : this.i18n(`which isn't used because of the inheritance of {{quotaValue}}`, {
528                   quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
529                 })
530             : this.i18n('in order to have no quota on the directory')
531       }),
532       onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.close())
533     });
534   }
535
536   createSnapshot() {
537     // Create a snapshot. Auto-generate a snapshot name by default.
538     const path = this.selectedDir.path;
539     this.modalService.show(FormModalComponent, {
540       titleText: this.i18n('Create Snapshot'),
541       message: this.i18n('Please enter the name of the snapshot.'),
542       fields: [
543         {
544           type: 'text',
545           name: 'name',
546           value: `${moment().toISOString(true)}`,
547           required: true
548         }
549       ],
550       submitButtonText: this.i18n('Create Snapshot'),
551       onSubmit: (values: CephfsSnapshot) => {
552         this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
553           this.notificationService.show(
554             NotificationType.success,
555             this.i18n(`Created snapshot '{{name}}' for '{{path}}'`, {
556               name: name,
557               path: path
558             })
559           );
560           this.forceDirRefresh();
561         });
562       }
563     });
564   }
565
566   /**
567    * Forces an update of the current selected directory
568    *
569    * As all nodes point by their path on an directory object, the easiest way is to update
570    * the objects by merge with their latest change.
571    */
572   private forceDirRefresh(path?: string) {
573     if (!path) {
574       const dir = this.selectedDir;
575       if (!dir) {
576         throw new Error('This function can only be called without path if an selection was made');
577       }
578       // Parent has to be called in order to update the object referring
579       // to the current selected directory
580       path = dir.parent ? dir.parent : dir.path;
581     }
582     const node = this.getNode(path);
583     node.loadNodeChildren();
584   }
585
586   private updateTreeStructure(dirs: CephfsDir[]) {
587     const getChildrenAndPaths = (
588       directories: CephfsDir[],
589       parent: string
590     ): { children: CephfsDir[]; paths: string[] } => {
591       const children = directories.filter((d) => d.parent === parent);
592       const paths = children.map((d) => d.path);
593       return { children, paths };
594     };
595
596     const parents = _.uniq(dirs.map((d) => d.parent).sort());
597     parents.forEach((p) => {
598       const received = getChildrenAndPaths(dirs, p);
599       const cached = getChildrenAndPaths(this.dirs, p);
600
601       cached.children.forEach((d) => {
602         if (!received.paths.includes(d.path)) {
603           this.removeOldDirectory(d);
604         }
605       });
606       received.children.forEach((d) => {
607         if (cached.paths.includes(d.path)) {
608           this.updateExistingDirectory(cached.children, d);
609         } else {
610           this.addNewDirectory(d);
611         }
612       });
613     });
614   }
615
616   private removeOldDirectory(rmDir: CephfsDir) {
617     const path = rmDir.path;
618     // Remove directory from local variables
619     _.remove(this.dirs, (d) => d.path === path);
620     delete this.nodeIds[path];
621     this.updateDirectoriesParentNode(rmDir);
622   }
623
624   private updateDirectoriesParentNode(dir: CephfsDir) {
625     const parent = dir.parent;
626     if (!parent) {
627       return;
628     }
629     const node = this.getNode(parent);
630     if (!node) {
631       // Node will not be found for new sub sub directories - this is the intended behaviour
632       return;
633     }
634     const children = this.getChildren(parent);
635     node.data.children = children;
636     node.data.hasChildren = children.length > 0;
637     this.treeComponent.treeModel.update();
638   }
639
640   private addNewDirectory(newDir: CephfsDir) {
641     this.dirs.push(newDir);
642     this.nodeIds[newDir.path] = newDir;
643     this.updateDirectoriesParentNode(newDir);
644   }
645
646   private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
647     const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
648     Object.assign(currentDirObject, updatedDir);
649   }
650
651   private updateQuotaTable() {
652     const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
653     if (node && node.id !== '/') {
654       this.setSettings(node);
655     }
656   }
657
658   private updateTree(force: boolean = false) {
659     if (this.loadingIndicator && !force) {
660       // In order to make the page scrollable during load, the render cycle for each node
661       // is omitted and only be called if all updates were loaded.
662       return;
663     }
664     this.treeComponent.treeModel.update();
665     this.nodes = [...this.nodes];
666     this.treeComponent.sizeChanged();
667   }
668
669   deleteSnapshotModal() {
670     this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
671       itemDescription: this.i18n('CephFs Snapshot'),
672       itemNames: this.snapshot.selection.selected.map((snapshot: CephfsSnapshot) => snapshot.name),
673       submitAction: () => this.deleteSnapshot()
674     });
675   }
676
677   deleteSnapshot() {
678     const path = this.selectedDir.path;
679     this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
680       const name = snapshot.name;
681       this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
682         this.notificationService.show(
683           NotificationType.success,
684           this.i18n(`Deleted snapshot '{{name}}' for '{{path}}'`, {
685             name: name,
686             path: path
687           })
688         );
689       });
690     });
691     this.modalRef.close();
692     this.forceDirRefresh();
693   }
694
695   refreshAllDirectories() {
696     // In order to make the page scrollable during load, the render cycle for each node
697     // is omitted and only be called if all updates were loaded.
698     this.loadingIndicator = true;
699     this.requestedPaths.map((path) => this.forceDirRefresh(path));
700     const interval = setInterval(() => {
701       this.updateTree(true);
702       if (!this.loadingIndicator) {
703         clearInterval(interval);
704       }
705     }, 3000);
706   }
707
708   unsetLoadingIndicator() {
709     if (!this.loadingIndicator) {
710       return;
711     }
712     clearTimeout(this.loadingTimeout);
713     this.loadingTimeout = setTimeout(() => {
714       const loading = Object.values(this.loading).some((l) => l);
715       if (loading) {
716         return this.unsetLoadingIndicator();
717       }
718       this.loadingIndicator = false;
719       this.updateTree();
720       // The problem is that we can't subscribe to an useful updated tree event and the time
721       // between fetching all calls and rebuilding the tree can take some time
722     }, 3000);
723   }
724 }