new features

This commit is contained in:
Mateusz Gruszczyński
2026-04-14 15:43:25 +02:00
parent 1a2ae0d607
commit 92a0f99fb3
17 changed files with 580 additions and 154 deletions

View File

@@ -4,10 +4,11 @@
[subtitle]="subtitle"
>
<div header-actions class="header-actions-row">
<button *ngIf="!isSwitchos" pButton type="button" icon="pi pi-upload" [label]="'routers.exportOne' | translate" [loading]="exporting" (click)="runExport()"></button>
<button pButton type="button" severity="secondary" icon="pi pi-database" [label]="(isSwitchos ? 'routers.downloadSwitchBackup' : 'routers.binaryOne') | translate" [loading]="runningBinary" (click)="runBinary()"></button>
<button pButton type="button" severity="secondary" icon="pi pi-pencil" [label]="'common.edit' | translate" (click)="openEdit()"></button>
<button *ngIf="!isSwitchos" pButton type="button" icon="pi pi-upload" [label]="'routers.exportOne' | translate" [loading]="exporting" [disabled]="routerItem?.disable_all_backups || routerItem?.disable_export_backups" (click)="runExport()"></button>
<button pButton type="button" severity="secondary" icon="pi pi-database" [label]="(isSwitchos ? 'routers.downloadSwitchBackup' : 'routers.binaryOne') | translate" [loading]="runningBinary" [disabled]="routerItem?.disable_all_backups || routerItem?.disable_binary_backups" (click)="runBinary()"></button>
<button pButton type="button" severity="info" icon="pi pi-wifi" [label]="'routers.testConnection' | translate" [loading]="testing" (click)="testConnection()"></button>
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'routers.deleteRouter' | translate" [loading]="deletingRouter" (click)="deleteRouter()"></button>
<button pButton type="button" severity="danger" icon="pi pi-trash" [label]="'routers.deleteDevice' | translate" [loading]="deletingRouter" (click)="deleteRouter()"></button>
</div>
</app-page-header>
@@ -46,43 +47,93 @@
</ng-template>
</app-section-card>
<div class="router-detail-inspection-stack" *ngIf="!isSwitchos">
<app-section-card [title]="'routers.previewTitle' | translate" [subtitle]="'routers.previewSubtitle' | translate">
<div class="router-modal-summary" *ngIf="hasPreview; else noPreview">
<div>
<strong>{{ previewTitle }}</strong>
<small>{{ 'routers.previewModalHint' | translate }}</small>
</div>
<div class="dialog-actions">
<button pButton type="button" severity="info" icon="pi pi-eye" [label]="'routers.openPreviewModal' | translate" (click)="openPreviewModal()"></button>
</div>
<app-section-card [title]="'routers.backupSettingsTitle' | translate" [subtitle]="'routers.backupSettingsHint' | translate">
<form [formGroup]="settingsForm" class="device-settings-form" (ngSubmit)="saveSettings()">
<div class="device-settings-stack">
<label class="device-toggle device-toggle--primary" [class.is-active]="settingsForm.controls.disable_all_backups.value">
<input type="checkbox" formControlName="disable_all_backups" />
<span class="device-toggle__switch" aria-hidden="true"></span>
<span class="device-toggle__icon"><i class="pi pi-ban"></i></span>
<span class="device-toggle__content">
<strong>{{ 'routers.disableAllBackups' | translate }}</strong>
<small>{{ 'routers.disableAllBackupsHint' | translate }}</small>
</span>
<span class="device-toggle__state">{{ (settingsForm.controls.disable_all_backups.value ? 'common.enabled' : 'common.disabled') | translate }}</span>
</label>
<label class="device-toggle" *ngIf="!isSwitchos" [class.is-active]="settingsForm.controls.disable_export_backups.value">
<input type="checkbox" formControlName="disable_export_backups" />
<span class="device-toggle__switch" aria-hidden="true"></span>
<span class="device-toggle__icon"><i class="pi pi-file-export"></i></span>
<span class="device-toggle__content">
<strong>{{ 'routers.disableExports' | translate }}</strong>
<small>{{ 'routers.disableExportsHint' | translate }}</small>
</span>
<span class="device-toggle__state">{{ (settingsForm.controls.disable_export_backups.value ? 'common.enabled' : 'common.disabled') | translate }}</span>
</label>
<label class="device-toggle" [class.is-active]="settingsForm.controls.disable_binary_backups.value">
<input type="checkbox" formControlName="disable_binary_backups" />
<span class="device-toggle__switch" aria-hidden="true"></span>
<span class="device-toggle__icon"><i class="pi pi-database"></i></span>
<span class="device-toggle__content">
<strong>{{ 'routers.disableBinaryBackups' | translate }}</strong>
<small>{{ 'routers.disableBinaryBackupsHint' | translate }}</small>
</span>
<span class="device-toggle__state">{{ (settingsForm.controls.disable_binary_backups.value ? 'common.enabled' : 'common.disabled') | translate }}</span>
</label>
<label class="device-toggle" [class.is-active]="settingsForm.controls.disable_ping.value">
<input type="checkbox" formControlName="disable_ping" />
<span class="device-toggle__switch" aria-hidden="true"></span>
<span class="device-toggle__icon"><i class="pi pi-wifi"></i></span>
<span class="device-toggle__content">
<strong>{{ 'routers.disablePing' | translate }}</strong>
<small>{{ 'routers.disablePingHint' | translate }}</small>
</span>
<span class="device-toggle__state">{{ (settingsForm.controls.disable_ping.value ? 'common.enabled' : 'common.disabled') | translate }}</span>
</label>
</div>
<ng-template #noPreview>
<div class="empty-state compact-empty">
<i class="pi pi-eye"></i>
<p>{{ 'routers.noPreview' | translate }}</p>
</div>
</ng-template>
</app-section-card>
<div class="dialog-actions device-settings-actions">
<button pButton type="submit" [loading]="savingSettings" [disabled]="savingSettings" [label]="'common.save' | translate"></button>
</div>
</form>
</app-section-card>
</div>
<app-section-card [title]="'routers.diffTitle' | translate" [subtitle]="'routers.diffSubtitle' | translate">
<div class="router-modal-summary" *ngIf="hasDiff && diffData; else noDiff">
<div>
<strong>{{ diffData.left_file_name }} → {{ diffData.right_file_name }}</strong>
<small>{{ 'routers.diffModalHint' | translate }}</small>
</div>
<div class="dialog-actions">
<button pButton type="button" severity="help" icon="pi pi-code" [label]="'routers.openDiffModal' | translate" (click)="openDiffModal()"></button>
</div>
<div class="router-detail-split-grid" *ngIf="!isSwitchos">
<app-section-card [title]="'routers.previewTitle' | translate" [subtitle]="'routers.previewSubtitle' | translate">
<div class="router-modal-summary" *ngIf="hasPreview; else noPreview">
<div>
<strong>{{ previewTitle }}</strong>
<small>{{ 'routers.previewModalHint' | translate }}</small>
</div>
<ng-template #noDiff>
<div class="empty-state compact-empty">
<i class="pi pi-code"></i>
<p>{{ 'routers.noDiff' | translate }}</p>
</div>
</ng-template>
</app-section-card>
</div>
<div class="dialog-actions">
<button pButton type="button" severity="info" icon="pi pi-eye" [label]="'routers.openPreviewModal' | translate" (click)="openPreviewModal()"></button>
</div>
</div>
<ng-template #noPreview>
<div class="empty-state compact-empty">
<i class="pi pi-eye"></i>
<p>{{ 'routers.noPreview' | translate }}</p>
</div>
</ng-template>
</app-section-card>
<app-section-card [title]="'routers.diffTitle' | translate" [subtitle]="'routers.diffSubtitle' | translate">
<div class="router-modal-summary" *ngIf="hasDiff && diffData; else noDiff">
<div>
<strong>{{ diffData.left_file_name }} → {{ diffData.right_file_name }}</strong>
<small>{{ 'routers.diffModalHint' | translate }}</small>
</div>
<div class="dialog-actions">
<button pButton type="button" severity="help" icon="pi pi-code" [label]="'routers.openDiffModal' | translate" (click)="openDiffModal()"></button>
</div>
</div>
<ng-template #noDiff>
<div class="empty-state compact-empty">
<i class="pi pi-code"></i>
<p>{{ 'routers.noDiff' | translate }}</p>
</div>
</ng-template>
</app-section-card>
</div>
<div class="dashboard-grid router-detail-grid router-detail-grid--stack" *ngIf="!isSwitchos">
@@ -168,3 +219,84 @@
<pre class="code-preview preview-dialog__content">{{ diffText }}</pre>
</ng-template>
</p-dialog>
<p-dialog [(visible)]="editVisible" [modal]="true" [draggable]="false" [resizable]="false" [style]="{ width: 'min(760px, 96vw)' }" styleClass="router-dialog">
<ng-template pTemplate="header">
<div class="router-dialog-header">
<div class="router-dialog-header__icon">
<i class="pi" [ngClass]="selectedDeviceType === 'switchos' ? 'pi-sitemap' : 'pi-server'"></i>
</div>
<div class="router-dialog-header__text">
<div class="router-dialog-header__eyebrow">
{{ 'routers.deviceType' | translate }} · {{ selectedDeviceType === 'switchos' ? ('routers.switchos' | translate) : ('routers.routeros' | translate) }}
</div>
<div class="router-dialog-header__title">{{ 'routers.editDialogTitle' | translate }}</div>
<small>
{{
selectedDeviceType === 'switchos'
? ('routers.switchDialogSubtitle' | translate)
: ('routers.routerDialogSubtitle' | translate)
}}
</small>
</div>
</div>
</ng-template>
<form [formGroup]="form" (ngSubmit)="saveEdit()" class="router-dialog-form">
<section class="router-dialog-panel">
<div class="router-dialog-panel__header">
<div>
<strong>{{ 'routers.connectionSectionTitle' | translate }}</strong>
<p>{{ 'routers.connectionSectionHint' | translate }}</p>
</div>
</div>
<div class="form-grid-2 router-dialog-grid">
<span class="form-field">
<label>{{ 'routers.name' | translate }}</label>
<input pInputText formControlName="name" />
</span>
<span class="form-field">
<label>{{ 'routers.deviceType' | translate }}</label>
<p-select [appendTo]="'body'" [options]="deviceTypeOptions" formControlName="device_type" optionLabel="label" optionValue="value"></p-select>
</span>
<span class="form-field">
<label>{{ 'routers.host' | translate }}</label>
<input pInputText formControlName="host" />
</span>
<span class="form-field">
<label>{{ 'routers.port' | translate }}</label>
<input pInputText type="number" formControlName="port" />
</span>
</div>
</section>
<section class="router-dialog-panel">
<div class="router-dialog-panel__header">
<div>
<strong>{{ 'routers.credentialsSectionTitle' | translate }}</strong>
<p>{{ selectedDeviceType === 'switchos' ? ('routers.switchDialogSubtitle' | translate) : ('routers.routerDialogSubtitle' | translate) }}</p>
</div>
</div>
<div class="form-grid-2 router-dialog-grid">
<span class="form-field">
<label>{{ 'routers.sshUser' | translate }}</label>
<input pInputText formControlName="ssh_user" [placeholder]="selectedDeviceType === 'switchos' ? ('routers.switchUserPlaceholder' | translate) : 'admin'" />
</span>
<span class="form-field">
<label>{{ 'routers.sshPassword' | translate }}</label>
<input pInputText type="password" formControlName="ssh_password" [placeholder]="selectedDeviceType === 'switchos' ? ('routers.switchPasswordPlaceholder' | translate) : ('routers.optionalPassword' | translate)" />
</span>
<span class="form-field form-field--full" *ngIf="selectedDeviceType === 'routeros'">
<label>{{ 'routers.sshPrivateKey' | translate }}</label>
<textarea pTextarea formControlName="ssh_key" rows="8" [placeholder]="'routers.optionalPrivateKey' | translate"></textarea>
</span>
</div>
</section>
<div class="dialog-actions router-dialog-actions">
<button pButton type="button" severity="secondary" [label]="'common.cancel' | translate" (click)="editVisible=false"></button>
<button pButton type="submit" [disabled]="form.invalid || saving" [loading]="saving" [label]="'routers.saveRouter' | translate"></button>
</div>
</form>
</p-dialog>

View File

@@ -1,12 +1,16 @@
import { CommonModule } from '@angular/common';
import { HttpResponse } from '@angular/common/http';
import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { InputTextModule } from 'primeng/inputtext';
import { SelectModule } from 'primeng/select';
import { TableModule } from 'primeng/table';
import { TagModule } from 'primeng/tag';
import { TextareaModule } from 'primeng/textarea';
import { ApiService } from '../../core/services/api.service';
import { UiService } from '../../core/services/ui.service';
@@ -22,9 +26,16 @@ interface DeviceItem {
host: string;
port: number;
device_type: DeviceType;
ssh_user?: string | null;
ssh_password?: string | null;
ssh_key?: string | null;
effective_username?: string | null;
supports_export: boolean;
supports_restore_upload: boolean;
disable_all_backups?: boolean;
disable_export_backups?: boolean;
disable_binary_backups?: boolean;
disable_ping?: boolean;
last_connection_status?: boolean | null;
last_connection_tested_at?: string | null;
last_connection_error?: string | null;
@@ -80,7 +91,21 @@ interface BackupDiffResponse {
@Component({
standalone: true,
imports: [CommonModule, TranslateModule, ButtonModule, DialogModule, TableModule, TagModule, PageHeaderComponent, SectionCardComponent, StatCardComponent],
imports: [
CommonModule,
TranslateModule,
ReactiveFormsModule,
ButtonModule,
DialogModule,
TableModule,
TagModule,
InputTextModule,
SelectModule,
TextareaModule,
PageHeaderComponent,
SectionCardComponent,
StatCardComponent
],
templateUrl: './router-detail-page.component.html'
})
export class RouterDetailPageComponent implements OnInit {
@@ -88,6 +113,7 @@ export class RouterDetailPageComponent implements OnInit {
private readonly api = inject(ApiService);
private readonly router = inject(Router);
private readonly ui = inject(UiService);
private readonly fb = inject(FormBuilder);
routerId!: number;
routerItem: DeviceItem | null = null;
@@ -98,16 +124,42 @@ export class RouterDetailPageComponent implements OnInit {
previewTitle = '';
previewVisible = false;
diffVisible = false;
editVisible = false;
diffData: BackupDiffResponse | null = null;
exporting = false;
runningBinary = false;
testing = false;
deletingRouter = false;
saving = false;
savingSettings = false;
readonly deviceTypeOptions = [
{ label: 'RouterOS', value: 'routeros' },
{ label: 'SwitchOS', value: 'switchos' }
];
readonly form = this.fb.nonNullable.group({
name: ['', Validators.required],
device_type: ['routeros' as DeviceType, Validators.required],
host: ['', Validators.required],
port: [22, Validators.required],
ssh_user: ['admin'],
ssh_password: '',
ssh_key: ''
});
readonly settingsForm = this.fb.nonNullable.group({
disable_all_backups: false,
disable_export_backups: false,
disable_binary_backups: false,
disable_ping: false
});
get isSwitchos(): boolean {
return this.routerItem?.device_type === 'switchos';
}
get selectedDeviceType(): DeviceType {
return this.form.controls.device_type.value;
}
get exportBackups(): BackupItem[] {
return this.backups.filter((item) => item.backup_type === 'export');
}
@@ -145,6 +197,14 @@ export class RouterDetailPageComponent implements OnInit {
ngOnInit() {
this.routerId = Number(this.route.snapshot.paramMap.get('id'));
this.form.controls.device_type.valueChanges.subscribe((deviceType) => {
this.applyDeviceDefaults((deviceType || 'routeros') as DeviceType);
});
this.settingsForm.controls.disable_all_backups.valueChanges.subscribe((disabled) => {
if (disabled) {
this.settingsForm.patchValue({ disable_export_backups: true, disable_binary_backups: true }, { emitEvent: false });
}
});
this.load();
}
@@ -152,12 +212,86 @@ export class RouterDetailPageComponent implements OnInit {
this.api.http.get<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`).subscribe((routerItem) => {
this.routerItem = routerItem;
this.connection = this.mapStoredConnection(routerItem);
this.patchSettingsForm(routerItem);
});
this.api.http.get<BackupItem[]>(`${this.api.baseUrl}/backups/router/${this.routerId}`).subscribe((r) => (this.backups = r));
}
openEdit() {
if (!this.routerItem) {
return;
}
this.form.reset({
name: this.routerItem.name,
device_type: this.routerItem.device_type,
host: this.routerItem.host,
port: this.routerItem.port,
ssh_user: this.routerItem.ssh_user ?? '',
ssh_password: this.routerItem.ssh_password ?? '',
ssh_key: this.routerItem.ssh_key ?? ''
});
this.editVisible = true;
}
saveEdit() {
if (this.form.invalid || this.saving) {
return;
}
this.saving = true;
const payload = this.form.getRawValue();
if (payload.device_type === 'switchos') {
payload.ssh_key = '';
}
this.api.http.put<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`, payload).subscribe({
next: (routerItem) => {
this.routerItem = routerItem;
this.connection = this.mapStoredConnection(routerItem);
this.editVisible = false;
this.ui.success('toast.routerUpdated');
},
complete: () => {
this.saving = false;
}
});
}
saveSettings() {
if (!this.routerItem || this.savingSettings) {
return;
}
this.savingSettings = true;
const payload = this.settingsForm.getRawValue();
if (this.routerItem.device_type === 'switchos') {
payload.disable_export_backups = true;
}
if (payload.disable_all_backups) {
payload.disable_export_backups = true;
payload.disable_binary_backups = true;
}
this.api.http.put<DeviceItem>(`${this.api.baseUrl}/routers/${this.routerId}`, payload).subscribe({
next: (routerItem) => {
this.routerItem = routerItem;
this.connection = this.mapStoredConnection(routerItem);
this.patchSettingsForm(routerItem);
this.ui.success('toast.routerUpdated');
},
complete: () => {
this.savingSettings = false;
}
});
}
private patchSettingsForm(item: DeviceItem) {
this.settingsForm.reset({
disable_all_backups: !!item.disable_all_backups,
disable_export_backups: !!item.disable_export_backups,
disable_binary_backups: !!item.disable_binary_backups,
disable_ping: !!item.disable_ping
}, { emitEvent: false });
}
runExport() {
if (this.exporting || this.isSwitchos) {
if (this.exporting || this.isSwitchos || this.routerItem?.disable_all_backups || this.routerItem?.disable_export_backups) {
return;
}
this.exporting = true;
@@ -173,7 +307,7 @@ export class RouterDetailPageComponent implements OnInit {
}
runBinary() {
if (this.runningBinary) {
if (this.runningBinary || this.routerItem?.disable_all_backups || this.routerItem?.disable_binary_backups) {
return;
}
this.runningBinary = true;
@@ -332,6 +466,14 @@ export class RouterDetailPageComponent implements OnInit {
};
}
private applyDeviceDefaults(deviceType: DeviceType) {
if (deviceType === 'switchos') {
this.form.patchValue({ port: 80, ssh_key: '', ssh_user: this.form.controls.ssh_user.value || '' }, { emitEvent: false });
return;
}
this.form.patchValue({ port: 22, ssh_user: this.form.controls.ssh_user.value || 'admin' }, { emitEvent: false });
}
private openBlob(response: HttpResponse<Blob>, fallbackName: string) {
const disposition = response.headers.get('content-disposition') || '';
const match = disposition.match(/filename="?([^";]+)"?/i);

View File

@@ -24,7 +24,7 @@
<app-section-card [title]="'routers.listTitle' | translate" [subtitle]="'routers.listSubtitle' | translate">
<p-table [value]="routers" responsiveLayout="scroll" styleClass="app-table">
<ng-template pTemplate="header">
<tr><th>{{ 'routers.name' | translate }}</th><th>{{ 'routers.endpoint' | translate }}</th><th>{{ 'routers.access' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
<tr><th>{{ 'routers.name' | translate }}</th><th>{{ 'routers.endpoint' | translate }}</th><th>{{ 'routers.access' | translate }}</th><th>{{ 'routers.backupPolicy' | translate }}</th><th>{{ 'routers.ping' | translate }}</th><th>{{ 'common.actions' | translate }}</th></tr>
</ng-template>
<ng-template pTemplate="body" let-routerItem>
<tr>
@@ -42,6 +42,13 @@
<p-tag [value]="secondaryAccessTag(routerItem).value" [severity]="secondaryAccessTag(routerItem).severity"></p-tag>
</div>
</td>
<td>
<div class="table-primary">{{ backupPolicyLabel(routerItem) }}</div>
<small class="table-secondary" *ngIf="routerItem.disable_all_backups">{{ 'common.disabled' | translate }}</small>
</td>
<td>
<small class="table-secondary">{{ pingLabel(routerItem) }}</small>
</td>
<td>
<div class="table-actions table-actions--labels">
<button pButton type="button" size="small" styleClass="table-action-btn table-action-btn--wide" icon="pi pi-arrow-right" [label]="'common.open' | translate" (click)="open(routerItem.id)"></button>

View File

@@ -32,6 +32,17 @@ interface RouterItem {
has_effective_ssh_key?: boolean;
uses_global_switchos_credentials?: boolean;
has_effective_password?: boolean;
disable_all_backups?: boolean;
disable_export_backups?: boolean;
disable_binary_backups?: boolean;
disable_ping?: boolean;
}
interface RouterPingStatus {
router_id: number;
reachable: boolean;
latency_ms?: number | null;
disabled?: boolean;
}
@Component({
@@ -62,6 +73,7 @@ export class RoutersPageComponent implements OnInit {
editingId: number | null = null;
saving = false;
routers: RouterItem[] = [];
pingStatuses: Record<number, RouterPingStatus> = {};
readonly deviceTypeOptions = [
{ label: 'RouterOS', value: 'routeros' },
{ label: 'SwitchOS', value: 'switchos' }
@@ -100,12 +112,31 @@ export class RoutersPageComponent implements OnInit {
}
load() {
this.api.http.get<RouterItem[]>(`${this.api.baseUrl}/routers`).subscribe((r) => (this.routers = r));
this.api.http.get<RouterItem[]>(`${this.api.baseUrl}/routers`).subscribe((r) => {
this.routers = r;
this.loadPingStatuses();
});
}
loadPingStatuses() {
this.api.http.get<{ items: RouterPingStatus[] }>(`${this.api.baseUrl}/routers/ping-statuses`).subscribe({
next: (response) => {
this.pingStatuses = response.items.reduce<Record<number, RouterPingStatus>>((acc, item) => {
acc[item.router_id] = item;
return acc;
}, {});
},
error: () => {
this.pingStatuses = {};
}
});
}
openCreate() {
this.editingId = null;
this.form.reset({ name: '', device_type: 'routeros', host: '', port: 22, ssh_user: 'admin', ssh_password: '', ssh_key: '' });
this.form.reset({
name: '', device_type: 'routeros', host: '', port: 22, ssh_user: 'admin', ssh_password: '', ssh_key: ''
});
this.visible = true;
}
@@ -174,6 +205,35 @@ export class RoutersPageComponent implements OnInit {
return item.effective_username || item.ssh_user || '—';
}
pingLabel(item: RouterItem): string {
if (item.disable_ping) {
return this.ui.instant('routers.pingDisabled');
}
const ping = this.pingStatuses[item.id];
if (!ping) {
return this.ui.instant('routers.pingChecking');
}
if (!ping.reachable) {
return this.ui.instant('routers.noPing');
}
const value = typeof ping.latency_ms === 'number' ? Math.round(ping.latency_ms) : null;
return value === null ? this.ui.instant('routers.pingAvailable') : `${this.ui.instant('routers.ping')}: ${value} ms`;
}
backupPolicyLabel(item: RouterItem): string {
if (item.disable_all_backups) {
return this.ui.instant('routers.backupsDisabledAll');
}
const parts: string[] = [];
if (!item.disable_export_backups && item.device_type === 'routeros') {
parts.push(this.ui.instant('routers.exportOne'));
}
if (!item.disable_binary_backups) {
parts.push(this.ui.instant('routers.binaryOne'));
}
return parts.length ? parts.join(' / ') : this.ui.instant('routers.backupsDisabledAll');
}
primaryAccessTag(item: RouterItem): { value: string; severity: 'success' | 'warn' | 'secondary' | 'info' } {
if (item.device_type === 'switchos') {
if (item.uses_global_switchos_credentials) {