]>
Commit | Line | Data |
---|---|---|
a4b75251 | 1 | import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; |
9f95a23c | 2 | import { Router } from '@angular/router'; |
11fdf7f2 | 3 | |
f67539c2 TL |
4 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; |
5 | import _ from 'lodash'; | |
a4b75251 TL |
6 | import { Subscription } from 'rxjs'; |
7 | import { map, mergeMap } from 'rxjs/operators'; | |
11fdf7f2 | 8 | |
f67539c2 TL |
9 | import { HostService } from '~/app/shared/api/host.service'; |
10 | import { OrchestratorService } from '~/app/shared/api/orchestrator.service'; | |
11 | import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; | |
12 | import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component'; | |
13 | import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; | |
14 | import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component'; | |
15 | import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; | |
a4b75251 | 16 | import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; |
f67539c2 TL |
17 | import { TableComponent } from '~/app/shared/datatable/table/table.component'; |
18 | import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; | |
19 | import { Icons } from '~/app/shared/enum/icons.enum'; | |
20 | import { NotificationType } from '~/app/shared/enum/notification-type.enum'; | |
21 | import { CdTableAction } from '~/app/shared/models/cd-table-action'; | |
22 | import { CdTableColumn } from '~/app/shared/models/cd-table-column'; | |
23 | import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; | |
24 | import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; | |
25 | import { FinishedTask } from '~/app/shared/models/finished-task'; | |
26 | import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum'; | |
27 | import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface'; | |
28 | import { Permissions } from '~/app/shared/models/permissions'; | |
a4b75251 | 29 | import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; |
f67539c2 TL |
30 | import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; |
31 | import { ModalService } from '~/app/shared/services/modal.service'; | |
32 | import { NotificationService } from '~/app/shared/services/notification.service'; | |
33 | import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; | |
34 | import { URLBuilderService } from '~/app/shared/services/url-builder.service'; | |
a4b75251 | 35 | import { HostFormComponent } from './host-form/host-form.component'; |
9f95a23c TL |
36 | |
37 | const BASE_URL = 'hosts'; | |
11fdf7f2 TL |
38 | |
39 | @Component({ | |
40 | selector: 'cd-hosts', | |
41 | templateUrl: './hosts.component.html', | |
9f95a23c TL |
42 | styleUrls: ['./hosts.component.scss'], |
43 | providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }] | |
11fdf7f2 | 44 | }) |
a4b75251 TL |
45 | export class HostsComponent extends ListWithDetails implements OnDestroy, OnInit { |
46 | private sub = new Subscription(); | |
47 | ||
f67539c2 | 48 | @ViewChild(TableComponent) |
f6b5b4d7 TL |
49 | table: TableComponent; |
50 | @ViewChild('servicesTpl', { static: true }) | |
51 | public servicesTpl: TemplateRef<any>; | |
f67539c2 TL |
52 | @ViewChild('maintenanceConfirmTpl', { static: true }) |
53 | maintenanceConfirmTpl: TemplateRef<any>; | |
a4b75251 TL |
54 | @ViewChild('orchTmpl', { static: true }) |
55 | orchTmpl: TemplateRef<any>; | |
56 | @ViewChild('flashTmpl', { static: true }) | |
57 | flashTmpl: TemplateRef<any>; | |
58 | ||
59 | @Input() | |
60 | hiddenColumns: string[] = []; | |
61 | ||
62 | @Input() | |
63 | hideTitle = false; | |
64 | ||
65 | @Input() | |
66 | hideSubmitBtn = false; | |
67 | ||
68 | @Input() | |
69 | hasTableDetails = true; | |
70 | ||
71 | @Input() | |
72 | hideToolHeader = false; | |
73 | ||
74 | @Input() | |
75 | showGeneralActionsOnly = false; | |
f6b5b4d7 | 76 | |
11fdf7f2 TL |
77 | permissions: Permissions; |
78 | columns: Array<CdTableColumn> = []; | |
79 | hosts: Array<object> = []; | |
80 | isLoadingHosts = false; | |
81 | cdParams = { fromLink: '/hosts' }; | |
9f95a23c | 82 | tableActions: CdTableAction[]; |
11fdf7f2 | 83 | selection = new CdTableSelection(); |
f67539c2 TL |
84 | modalRef: NgbModalRef; |
85 | isExecuting = false; | |
86 | errorMessage: string; | |
20effc67 TL |
87 | enableMaintenanceBtn: boolean; |
88 | enableDrainBtn: boolean; | |
a4b75251 | 89 | bsModalRef: NgbModalRef; |
f67539c2 TL |
90 | |
91 | icons = Icons; | |
92 | ||
93 | messages = { | |
94 | nonOrchHost: $localize`The feature is disabled because the selected host is not managed by Orchestrator.` | |
95 | }; | |
96 | ||
97 | orchStatus: OrchestratorStatus; | |
98 | actionOrchFeatures = { | |
a4b75251 | 99 | add: [OrchestratorFeature.HOST_ADD], |
f67539c2 | 100 | edit: [OrchestratorFeature.HOST_LABEL_ADD, OrchestratorFeature.HOST_LABEL_REMOVE], |
a4b75251 | 101 | remove: [OrchestratorFeature.HOST_REMOVE], |
f67539c2 TL |
102 | maintenance: [ |
103 | OrchestratorFeature.HOST_MAINTENANCE_ENTER, | |
104 | OrchestratorFeature.HOST_MAINTENANCE_EXIT | |
20effc67 TL |
105 | ], |
106 | drain: [OrchestratorFeature.HOST_DRAIN] | |
f67539c2 | 107 | }; |
11fdf7f2 | 108 | |
11fdf7f2 TL |
109 | constructor( |
110 | private authStorageService: AuthStorageService, | |
a4b75251 | 111 | private dimlessBinary: DimlessBinaryPipe, |
11fdf7f2 | 112 | private hostService: HostService, |
9f95a23c | 113 | private actionLabels: ActionLabelsI18n, |
f67539c2 | 114 | private modalService: ModalService, |
9f95a23c TL |
115 | private taskWrapper: TaskWrapperService, |
116 | private router: Router, | |
f67539c2 TL |
117 | private notificationService: NotificationService, |
118 | private orchService: OrchestratorService | |
11fdf7f2 | 119 | ) { |
e306af50 | 120 | super(); |
11fdf7f2 | 121 | this.permissions = this.authStorageService.getPermissions(); |
9f95a23c TL |
122 | this.tableActions = [ |
123 | { | |
a4b75251 | 124 | name: this.actionLabels.ADD, |
9f95a23c TL |
125 | permission: 'create', |
126 | icon: Icons.add, | |
a4b75251 TL |
127 | click: () => |
128 | this.router.url.includes('/hosts') | |
129 | ? this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.ADD] } }]) | |
130 | : (this.bsModalRef = this.modalService.show(HostFormComponent)), | |
131 | disable: (selection: CdTableSelection) => this.getDisable('add', selection) | |
9f95a23c | 132 | }, |
f6b5b4d7 TL |
133 | { |
134 | name: this.actionLabels.EDIT, | |
135 | permission: 'update', | |
136 | icon: Icons.edit, | |
f67539c2 TL |
137 | click: () => this.editAction(), |
138 | disable: (selection: CdTableSelection) => this.getDisable('edit', selection) | |
f6b5b4d7 | 139 | }, |
20effc67 TL |
140 | { |
141 | name: this.actionLabels.START_DRAIN, | |
142 | permission: 'update', | |
143 | icon: Icons.exit, | |
144 | click: () => this.hostDrain(), | |
145 | disable: (selection: CdTableSelection) => | |
146 | this.getDisable('drain', selection) || !this.enableDrainBtn, | |
147 | visible: () => !this.showGeneralActionsOnly && this.enableDrainBtn | |
148 | }, | |
149 | { | |
150 | name: this.actionLabels.STOP_DRAIN, | |
151 | permission: 'update', | |
152 | icon: Icons.exit, | |
153 | click: () => this.hostDrain(true), | |
154 | disable: (selection: CdTableSelection) => | |
155 | this.getDisable('drain', selection) || this.enableDrainBtn, | |
156 | visible: () => !this.showGeneralActionsOnly && !this.enableDrainBtn | |
157 | }, | |
9f95a23c | 158 | { |
a4b75251 | 159 | name: this.actionLabels.REMOVE, |
9f95a23c TL |
160 | permission: 'delete', |
161 | icon: Icons.destroy, | |
f67539c2 | 162 | click: () => this.deleteAction(), |
a4b75251 | 163 | disable: (selection: CdTableSelection) => this.getDisable('remove', selection) |
f67539c2 TL |
164 | }, |
165 | { | |
166 | name: this.actionLabels.ENTER_MAINTENANCE, | |
167 | permission: 'update', | |
168 | icon: Icons.enter, | |
169 | click: () => this.hostMaintenance(), | |
170 | disable: (selection: CdTableSelection) => | |
20effc67 TL |
171 | this.getDisable('maintenance', selection) || |
172 | this.isExecuting || | |
173 | this.enableMaintenanceBtn, | |
174 | visible: () => !this.showGeneralActionsOnly && !this.enableMaintenanceBtn | |
f67539c2 TL |
175 | }, |
176 | { | |
177 | name: this.actionLabels.EXIT_MAINTENANCE, | |
178 | permission: 'update', | |
179 | icon: Icons.exit, | |
180 | click: () => this.hostMaintenance(), | |
181 | disable: (selection: CdTableSelection) => | |
20effc67 TL |
182 | this.getDisable('maintenance', selection) || |
183 | this.isExecuting || | |
184 | !this.enableMaintenanceBtn, | |
185 | visible: () => !this.showGeneralActionsOnly && this.enableMaintenanceBtn | |
9f95a23c TL |
186 | } |
187 | ]; | |
11fdf7f2 TL |
188 | } |
189 | ||
190 | ngOnInit() { | |
191 | this.columns = [ | |
192 | { | |
f67539c2 | 193 | name: $localize`Hostname`, |
11fdf7f2 TL |
194 | prop: 'hostname', |
195 | flexGrow: 1 | |
196 | }, | |
197 | { | |
f67539c2 | 198 | name: $localize`Services`, |
11fdf7f2 | 199 | prop: 'services', |
a4b75251 | 200 | flexGrow: 2, |
11fdf7f2 TL |
201 | cellTemplate: this.servicesTpl |
202 | }, | |
e306af50 | 203 | { |
f67539c2 | 204 | name: $localize`Labels`, |
e306af50 TL |
205 | prop: 'labels', |
206 | flexGrow: 1, | |
f67539c2 TL |
207 | cellTransformation: CellTemplate.badge, |
208 | customTemplateConfig: { | |
209 | class: 'badge-dark' | |
210 | } | |
211 | }, | |
212 | { | |
213 | name: $localize`Status`, | |
214 | prop: 'status', | |
215 | flexGrow: 1, | |
216 | cellTransformation: CellTemplate.badge, | |
217 | customTemplateConfig: { | |
218 | map: { | |
219 | maintenance: { class: 'badge-warning' } | |
220 | } | |
221 | } | |
e306af50 | 222 | }, |
11fdf7f2 | 223 | { |
a4b75251 TL |
224 | name: $localize`Model`, |
225 | prop: 'model', | |
226 | flexGrow: 1 | |
227 | }, | |
228 | { | |
229 | name: $localize`CPUs`, | |
230 | prop: 'cpu_count', | |
231 | flexGrow: 0.3 | |
232 | }, | |
233 | { | |
234 | name: $localize`Cores`, | |
235 | prop: 'cpu_cores', | |
236 | flexGrow: 0.3 | |
237 | }, | |
238 | { | |
239 | name: $localize`Total Memory`, | |
240 | prop: 'memory_total_bytes', | |
241 | pipe: this.dimlessBinary, | |
242 | flexGrow: 0.4 | |
243 | }, | |
244 | { | |
245 | name: $localize`Raw Capacity`, | |
246 | prop: 'raw_capacity', | |
247 | pipe: this.dimlessBinary, | |
248 | flexGrow: 0.5 | |
249 | }, | |
250 | { | |
251 | name: $localize`HDDs`, | |
252 | prop: 'hdd_count', | |
253 | flexGrow: 0.3 | |
254 | }, | |
255 | { | |
256 | name: $localize`Flash`, | |
257 | prop: 'flash_count', | |
258 | headerTemplate: this.flashTmpl, | |
259 | flexGrow: 0.3 | |
260 | }, | |
261 | { | |
262 | name: $localize`NICs`, | |
263 | prop: 'nic_count', | |
264 | flexGrow: 0.3 | |
11fdf7f2 TL |
265 | } |
266 | ]; | |
a4b75251 TL |
267 | |
268 | this.columns = this.columns.filter((col: any) => { | |
269 | return !this.hiddenColumns.includes(col.prop); | |
f67539c2 | 270 | }); |
11fdf7f2 TL |
271 | } |
272 | ||
a4b75251 TL |
273 | ngOnDestroy() { |
274 | this.sub.unsubscribe(); | |
275 | } | |
276 | ||
11fdf7f2 TL |
277 | updateSelection(selection: CdTableSelection) { |
278 | this.selection = selection; | |
20effc67 TL |
279 | this.enableMaintenanceBtn = false; |
280 | this.enableDrainBtn = false; | |
f67539c2 TL |
281 | if (this.selection.hasSelection) { |
282 | if (this.selection.first().status === 'maintenance') { | |
20effc67 TL |
283 | this.enableMaintenanceBtn = true; |
284 | } | |
285 | ||
286 | if (!this.selection.first().labels.includes('_no_schedule')) { | |
287 | this.enableDrainBtn = true; | |
f67539c2 TL |
288 | } |
289 | } | |
11fdf7f2 TL |
290 | } |
291 | ||
f6b5b4d7 TL |
292 | editAction() { |
293 | this.hostService.getLabels().subscribe((resp: string[]) => { | |
294 | const host = this.selection.first(); | |
a4b75251 TL |
295 | const labels = new Set(resp.concat(this.hostService.predefinedLabels)); |
296 | const allLabels = Array.from(labels).map((label) => { | |
f6b5b4d7 TL |
297 | return { enabled: true, name: label }; |
298 | }); | |
299 | this.modalService.show(FormModalComponent, { | |
f67539c2 TL |
300 | titleText: $localize`Edit Host: ${host.hostname}`, |
301 | fields: [ | |
302 | { | |
303 | type: 'select-badges', | |
304 | name: 'labels', | |
305 | value: host['labels'], | |
306 | label: $localize`Labels`, | |
307 | typeConfig: { | |
308 | customBadges: true, | |
309 | options: allLabels, | |
310 | messages: new SelectMessages({ | |
311 | empty: $localize`There are no labels.`, | |
312 | filter: $localize`Filter or add labels`, | |
313 | add: $localize`Add label` | |
314 | }) | |
f6b5b4d7 | 315 | } |
f6b5b4d7 | 316 | } |
f67539c2 TL |
317 | ], |
318 | submitButtonText: $localize`Edit Host`, | |
319 | onSubmit: (values: any) => { | |
320 | this.hostService.update(host['hostname'], true, values.labels).subscribe(() => { | |
321 | this.notificationService.show( | |
322 | NotificationType.success, | |
323 | $localize`Updated Host "${host.hostname}"` | |
324 | ); | |
325 | // Reload the data table content. | |
326 | this.table.refreshBtn(); | |
327 | }); | |
f6b5b4d7 TL |
328 | } |
329 | }); | |
330 | }); | |
331 | } | |
332 | ||
f67539c2 TL |
333 | hostMaintenance() { |
334 | this.isExecuting = true; | |
335 | const host = this.selection.first(); | |
336 | if (host['status'] !== 'maintenance') { | |
337 | this.hostService.update(host['hostname'], false, [], true).subscribe( | |
338 | () => { | |
339 | this.isExecuting = false; | |
340 | this.notificationService.show( | |
341 | NotificationType.success, | |
342 | $localize`"${host.hostname}" moved to maintenance` | |
343 | ); | |
344 | this.table.refreshBtn(); | |
345 | }, | |
346 | (error) => { | |
347 | this.isExecuting = false; | |
348 | this.errorMessage = error.error['detail'].split(/\n/); | |
349 | error.preventDefault(); | |
350 | if ( | |
351 | error.error['detail'].includes('WARNING') && | |
352 | !error.error['detail'].includes('It is NOT safe to stop') && | |
b3b6e05e | 353 | !error.error['detail'].includes('ALERT') && |
522d829b | 354 | !error.error['detail'].includes('unsafe to stop') |
f67539c2 | 355 | ) { |
522d829b | 356 | const modalVariables = { |
f67539c2 TL |
357 | titleText: $localize`Warning`, |
358 | buttonText: $localize`Continue`, | |
359 | warning: true, | |
360 | bodyTpl: this.maintenanceConfirmTpl, | |
361 | showSubmit: true, | |
362 | onSubmit: () => { | |
363 | this.hostService.update(host['hostname'], false, [], true, true).subscribe( | |
364 | () => { | |
365 | this.modalRef.close(); | |
366 | }, | |
367 | () => this.modalRef.close() | |
368 | ); | |
369 | } | |
370 | }; | |
522d829b | 371 | this.modalRef = this.modalService.show(ConfirmationModalComponent, modalVariables); |
f67539c2 TL |
372 | } else { |
373 | this.notificationService.show( | |
374 | NotificationType.error, | |
375 | $localize`"${host.hostname}" cannot be put into maintenance`, | |
376 | $localize`${error.error['detail']}` | |
377 | ); | |
378 | } | |
379 | } | |
380 | ); | |
381 | } else { | |
382 | this.hostService.update(host['hostname'], false, [], true).subscribe(() => { | |
383 | this.isExecuting = false; | |
384 | this.notificationService.show( | |
385 | NotificationType.success, | |
386 | $localize`"${host.hostname}" has exited maintenance` | |
f91f0fd5 | 387 | ); |
f67539c2 TL |
388 | this.table.refreshBtn(); |
389 | }); | |
f6b5b4d7 | 390 | } |
f67539c2 | 391 | } |
f91f0fd5 | 392 | |
20effc67 TL |
393 | hostDrain(stop = false) { |
394 | const host = this.selection.first(); | |
395 | if (stop) { | |
396 | const index = host['labels'].indexOf('_no_schedule', 0); | |
397 | host['labels'].splice(index, 1); | |
398 | this.hostService.update(host['hostname'], true, host['labels']).subscribe(() => { | |
399 | this.notificationService.show( | |
400 | NotificationType.info, | |
401 | $localize`"${host['hostname']}" stopped draining` | |
402 | ); | |
403 | this.table.refreshBtn(); | |
404 | }); | |
405 | } else { | |
406 | this.hostService.update(host['hostname'], false, [], false, false, true).subscribe(() => { | |
407 | this.notificationService.show( | |
408 | NotificationType.info, | |
409 | $localize`"${host['hostname']}" started draining` | |
410 | ); | |
411 | this.table.refreshBtn(); | |
412 | }); | |
413 | } | |
414 | } | |
415 | ||
f67539c2 | 416 | getDisable( |
20effc67 | 417 | action: 'add' | 'edit' | 'remove' | 'maintenance' | 'drain', |
f67539c2 TL |
418 | selection: CdTableSelection |
419 | ): boolean | string { | |
20effc67 TL |
420 | if ( |
421 | action === 'remove' || | |
422 | action === 'edit' || | |
423 | action === 'maintenance' || | |
424 | action === 'drain' | |
425 | ) { | |
f67539c2 TL |
426 | if (!selection?.hasSingleSelection) { |
427 | return true; | |
428 | } | |
429 | if (!_.every(selection.selected, 'sources.orchestrator')) { | |
430 | return this.messages.nonOrchHost; | |
431 | } | |
432 | } | |
433 | return this.orchService.getTableActionDisableDesc( | |
434 | this.orchStatus, | |
435 | this.actionOrchFeatures[action] | |
436 | ); | |
f6b5b4d7 TL |
437 | } |
438 | ||
439 | deleteAction() { | |
9f95a23c TL |
440 | const hostname = this.selection.first().hostname; |
441 | this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { | |
f67539c2 TL |
442 | itemDescription: 'Host', |
443 | itemNames: [hostname], | |
a4b75251 | 444 | actionDescription: 'remove', |
f67539c2 TL |
445 | submitActionObservable: () => |
446 | this.taskWrapper.wrapTaskAroundCall({ | |
a4b75251 | 447 | task: new FinishedTask('host/remove', { hostname: hostname }), |
f67539c2 TL |
448 | call: this.hostService.delete(hostname) |
449 | }) | |
9f95a23c TL |
450 | }); |
451 | } | |
452 | ||
a4b75251 TL |
453 | checkHostsFactsAvailable() { |
454 | const orchFeatures = this.orchStatus.features; | |
455 | if (!_.isEmpty(orchFeatures)) { | |
456 | if (orchFeatures.get_facts.available) { | |
457 | return true; | |
458 | } | |
459 | return false; | |
460 | } | |
461 | return false; | |
462 | } | |
463 | ||
464 | transformHostsData() { | |
465 | if (this.checkHostsFactsAvailable()) { | |
466 | _.forEach(this.hosts, (hostKey) => { | |
467 | hostKey['memory_total_bytes'] = hostKey['memory_total_kb'] * 1024; | |
468 | hostKey['raw_capacity'] = hostKey['hdd_capacity_bytes'] + hostKey['flash_capacity_bytes']; | |
469 | }); | |
470 | } else { | |
471 | // mark host facts columns unavailable | |
472 | for (let column = 4; column < this.columns.length; column++) { | |
473 | this.columns[column]['prop'] = ''; | |
474 | this.columns[column]['cellTemplate'] = this.orchTmpl; | |
475 | } | |
476 | } | |
477 | } | |
478 | ||
11fdf7f2 TL |
479 | getHosts(context: CdTableFetchDataContext) { |
480 | if (this.isLoadingHosts) { | |
481 | return; | |
482 | } | |
483 | const typeToPermissionKey = { | |
484 | mds: 'cephfs', | |
485 | mon: 'monitor', | |
486 | osd: 'osd', | |
487 | rgw: 'rgw', | |
488 | 'rbd-mirror': 'rbdMirroring', | |
489 | mgr: 'manager', | |
490 | 'tcmu-runner': 'iscsi' | |
491 | }; | |
492 | this.isLoadingHosts = true; | |
a4b75251 TL |
493 | this.sub = this.orchService |
494 | .status() | |
495 | .pipe( | |
496 | mergeMap((orchStatus) => { | |
497 | this.orchStatus = orchStatus; | |
498 | const factsAvailable = this.checkHostsFactsAvailable(); | |
499 | return this.hostService.list(`${factsAvailable}`); | |
500 | }), | |
501 | map((hostList: object[]) => | |
502 | hostList.map((host) => { | |
503 | host['services'].map((service: any) => { | |
504 | service.cdLink = `/perf_counters/${service.type}/${encodeURIComponent(service.id)}`; | |
505 | const permission = this.permissions[typeToPermissionKey[service.type]]; | |
506 | service.canRead = permission ? permission.read : false; | |
507 | return service; | |
508 | }); | |
509 | return host; | |
510 | }) | |
511 | ) | |
512 | ) | |
513 | .subscribe( | |
514 | (hostList) => { | |
515 | this.hosts = hostList; | |
516 | this.transformHostsData(); | |
517 | this.isLoadingHosts = false; | |
518 | }, | |
519 | () => { | |
520 | this.isLoadingHosts = false; | |
521 | context.error(); | |
522 | } | |
523 | ); | |
11fdf7f2 TL |
524 | } |
525 | } |