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