mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-27 02:41:53 +00:00
rework status display in service show
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<pwa-back-button></pwa-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Properties</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-buttons *ngIf="!loading" slot="end">
|
||||
<ion-button (click)="refresh()">
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
@@ -56,7 +56,7 @@
|
||||
<ion-button *ngIf="prop.value.masked" fill="clear" (click)="toggleMask(prop.key)">
|
||||
<ion-icon slot="icon-only" [name]="unmasked[prop.key] ? 'eye-off-outline' : 'eye-outline'" [color]="unmasked[prop.key] ? 'danger' : 'dark'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button *ngIf="prop.value.qr" fill="clear" (click)="showQR(prop.value.value, $event)">
|
||||
<ion-button *ngIf="prop.value.qr" fill="clear" (click)="showQR(prop.value.value)">
|
||||
<ion-icon slot="icon-only" name="qr-code-outline" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button *ngIf="prop.value.copyable" fill="clear" (click)="copy(prop.value.value)">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ActivatedRoute } from '@angular/router'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { copyToClipboard } from 'src/app/util/web.util'
|
||||
import { AlertController, IonContent, NavController, PopoverController, ToastController } from '@ionic/angular'
|
||||
import { AlertController, IonContent, ModalController, NavController, ToastController } from '@ionic/angular'
|
||||
import { PackageProperties } from 'src/app/util/properties.util'
|
||||
import { QRComponent } from 'src/app/components/qr/qr.component'
|
||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||
@@ -34,7 +34,7 @@ export class AppPropertiesPage {
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly popoverCtrl: PopoverController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly patch: PatchDbService,
|
||||
) { }
|
||||
@@ -100,16 +100,15 @@ export class AppPropertiesPage {
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
async showQR (text: string, ev: any): Promise<void> {
|
||||
const popover = await this.popoverCtrl.create({
|
||||
async showQR (text: string): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: QRComponent,
|
||||
cssClass: 'qr-popover',
|
||||
event: ev,
|
||||
componentProps: {
|
||||
text,
|
||||
},
|
||||
cssClass: 'qr-modal',
|
||||
})
|
||||
return await popover.present()
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
toggleMask (key: string) {
|
||||
|
||||
@@ -19,19 +19,11 @@
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<!-- ** always ** -->
|
||||
<!-- ** status ** -->
|
||||
<ion-item-divider>Status</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label style="overflow: visible;">
|
||||
<status [disconnected]="connectionFailure" size="x-large" weight="500" [rendering]="PR[statuses.primary]"></status>
|
||||
<span *ngIf="statuses.dependency">
|
||||
Dependencies:
|
||||
<status [disconnected]="connectionFailure" size="medium" weight="500" [rendering]="DR[statuses.dependency]"></status>
|
||||
</span>
|
||||
<span *ngIf="statuses.health">
|
||||
Health:
|
||||
<status [disconnected]="connectionFailure" size="medium" weight="500" [rendering]="HR[statuses.health]"></status>
|
||||
</span>
|
||||
</ion-label>
|
||||
<ng-container *ngIf="pkg.state === PackageState.Installed && !connectionFailure">
|
||||
<ion-button slot="end" class="action-button" *ngIf="pkg.manifest.interfaces | hasUi" [disabled]="!(pkg.state | isLaunchable : pkg.installed.status.main.status : pkg.manifest.interfaces)" (click)="launchUi()">
|
||||
@@ -44,15 +36,11 @@
|
||||
<ion-button slot="end" class="action-button" *ngIf="statuses.primary === PS.Running" color="danger" (click)="stop()">
|
||||
Stop
|
||||
</ion-button>
|
||||
<ion-button slot="end" class="action-button" *ngIf="statuses.dependency && status.dependency !== DS.Satisfied" (click)="scrollToRequirements()">
|
||||
Fix
|
||||
</ion-button>
|
||||
<ion-button slot="end" class="action-button" *ngIf="statuses.primary === PS.Stopped && statuses.dependency !== DS.Critical" color="success" (click)="tryStart()">
|
||||
<ion-button slot="end" class="action-button" *ngIf="statuses.primary === PS.Stopped && pkg.installed.status.configured && statuses.dependency !== DS.Critical" color="success" (click)="tryStart()">
|
||||
Start
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
|
||||
<!-- ** installed ** -->
|
||||
<ng-container *ngIf="pkg.state === PackageState.Installed">
|
||||
<!-- ** !restoring/backing-up ** -->
|
||||
@@ -87,55 +75,32 @@
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- ** dependencies ** -->
|
||||
<ng-container *ngIf="!(dependencies | empty)">
|
||||
<ion-item-divider>Dependencies</ion-item-divider>
|
||||
<!-- dependencies are a subset of the pkg.manifest.dependencies that are currently required as determined by the service config -->
|
||||
<ion-item button *ngFor="let dep of dependencies | keyvalue" (click)="dep.value.action()">
|
||||
<ion-thumbnail slot="start">
|
||||
<img [src]="dep.value.icon" />
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2 style="font-family: 'Montserrat'">{{ dep.value.title }}</h2>
|
||||
<p>{{ dep.value.version | displayEmver }}</p>
|
||||
<p><ion-text [color]="!!dep.value.errorText ? 'warning' : 'success'">{{ dep.value.errorText || 'satisfied' }}</ion-text></p>
|
||||
</ion-label>
|
||||
<ion-spinner *ngIf="dep.value.spinnerColor" slot="end" [color]="dep.value.spinnerColor" style="height: 3vh; width: 3vh"></ion-spinner>
|
||||
<ion-button *ngIf="dep.value.actionText" slot="end" fill="clear">
|
||||
{{ dep.value.actionText }}
|
||||
<ion-icon slot="end" name="arrow-forward"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<!-- ** menu ** -->
|
||||
<ion-item-divider>Menu</ion-item-divider>
|
||||
<ion-item button detail *ngFor="let button of buttons" (click)="button.action()">
|
||||
<ion-icon slot="start" [name]="button.icon"></ion-icon>
|
||||
<ion-label>{{ button.title }}</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- ** dependencies ** -->
|
||||
<ng-container *ngIf="!(currentDependencies | empty)">
|
||||
<ion-item-divider id="dependencies">Dependencies</ion-item-divider>
|
||||
<!-- A current-dependency is a subset of the pkg.manifest.dependencies that is currently required as determined by the service config. -->
|
||||
<ion-item *ngFor="let dep of currentDependencies | keyvalue">
|
||||
<ion-thumbnail slot="start">
|
||||
<img [src]="pkg.installed['dependency-info'][dep.key].icon" />
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2 style="font-family: 'Montserrat'">{{ pkg.installed['dependency-info'][dep.key].manifest.title }}</h2>
|
||||
<p>{{ pkg.manifest.dependencies[dep.key].version | displayEmver }}</p>
|
||||
<p><ion-text [color]="pkg.installed.status['dependency-errors'][dep.key] ? 'warning' : 'success'">{{ pkg.installed.status['dependency-errors'][dep.key] ? pkg.installed.status['dependency-errors'][dep.key].type : 'satisfied' }}</ion-text></p>
|
||||
</ion-label>
|
||||
|
||||
<ion-button *ngIf="!pkg.installed.status['dependency-errors'][dep.key] || (pkg.installed.status['dependency-errors'][dep.key] && [DependencyErrorType.InterfaceHealthChecksFailed, DependencyErrorType.HealthChecksFailed] | includes : pkg.installed.status['dependency-errors'][dep.key].type)" slot="end" size="small" [routerLink]="['/services', dep.key]">
|
||||
View
|
||||
</ion-button>
|
||||
|
||||
<ng-container *ngIf="pkg.installed.status['dependency-errors'][dep.key]">
|
||||
<ion-button *ngIf="!patch.data['package-data'][dep.key]" slot="end" size="small" (click)="fixDep('install', dep.key)">
|
||||
Install
|
||||
</ion-button>
|
||||
|
||||
<ng-container *ngIf="patch.data['package-data'][dep.key] && patch.data['package-data'][dep.key].state === PackageState.Installed">
|
||||
<ion-button *ngIf="pkg.installed.status['dependency-errors'][dep.key].type === DependencyErrorType.NotRunning" slot="end" size="small" [routerLink]="['/services', dep.key]">
|
||||
Start
|
||||
</ion-button>
|
||||
<ion-button *ngIf="pkg.installed.status['dependency-errors'][dep.key].type === DependencyErrorType.IncorrectVersion" slot="end" size="small" (click)="fixDep('update', dep.key)">
|
||||
Update
|
||||
</ion-button>
|
||||
<ion-button *ngIf="pkg.installed.status['dependency-errors'][dep.key].type === DependencyErrorType.ConfigUnsatisfied" slot="end" size="small" (click)="fixDep('configure', dep.key)">
|
||||
Configure
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="patch.data['package-data'][dep.key] && patch.data['package-data'][dep.key].state !== PackageState.Installed" slot="end">
|
||||
<ion-spinner [color]="patch.data['package-data'][dep.key].state === PackageState.Removing ? 'danger' : 'primary'" style="height: 3vh; width: 3vh" name="dots"></ion-spinner>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<!-- @TODO better maintenance messaging -->
|
||||
<ng-template #maintenance>
|
||||
|
||||
@@ -8,8 +8,8 @@ import { wizardModal } from 'src/app/components/install-wizard/install-wizard.co
|
||||
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||
import { CurrentDependencyInfo, DependencyErrorConfigUnsatisfied, DependencyErrorType, HealthCheckResult, PackageDataEntry, PackageMainStatus, PackageState } from 'src/app/services/patch-db/data-model'
|
||||
import { DependencyRendering, DependencyStatus, HealthRendering, HealthStatus, PrimaryRendering, PrimaryStatus, renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { DependencyErrorConfigUnsatisfied, DependencyErrorType, HealthCheckResult, PackageDataEntry, PackageMainStatus, PackageState } from 'src/app/services/patch-db/data-model'
|
||||
import { DependencyStatus, HealthStatus, PrimaryRendering, PrimaryStatus, renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { ConnectionFailure, ConnectionService } from 'src/app/services/connection.service'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { AppConfigPage } from 'src/app/modals/app-config/app-config.page'
|
||||
@@ -27,16 +27,14 @@ export class AppShowPage {
|
||||
Math = Math
|
||||
PS = PrimaryStatus
|
||||
DS = DependencyStatus
|
||||
HS = HealthStatus
|
||||
PR = PrimaryRendering
|
||||
DR = DependencyRendering
|
||||
HR = HealthRendering
|
||||
|
||||
pkgId: string
|
||||
pkg: PackageDataEntry
|
||||
hideLAN: boolean
|
||||
buttons: Button[] = []
|
||||
currentDependencies: { [id: string]: CurrentDependencyInfo }
|
||||
// currentDependencies: { [id: string]: CurrentDependencyInfo }
|
||||
dependencies: { [id: string]: DependencyInfo } = { }
|
||||
statuses: {
|
||||
primary: PrimaryStatus
|
||||
dependency: DependencyStatus
|
||||
@@ -61,19 +59,19 @@ export class AppShowPage {
|
||||
private readonly wizardBaker: WizardBaker,
|
||||
private readonly config: ConfigService,
|
||||
private readonly packageLoadingService: PackageLoadingService,
|
||||
public readonly patch: PatchDbService,
|
||||
public readonly connectionService: ConnectionService,
|
||||
private readonly patch: PatchDbService,
|
||||
private readonly connectionService: ConnectionService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
|
||||
this.setValues(this.patch.data['package-data'][this.pkgId])
|
||||
this.setValues(this.patch.data['package-data'])
|
||||
|
||||
this.subs = [
|
||||
// 1
|
||||
this.patch.watch$('package-data', this.pkgId)
|
||||
.subscribe(pkg => {
|
||||
this.setValues(pkg)
|
||||
this.patch.watch$('package-data')
|
||||
.subscribe(pkgs => {
|
||||
this.setValues(pkgs)
|
||||
}),
|
||||
// 2
|
||||
this.connectionService.watchFailure$()
|
||||
@@ -148,13 +146,6 @@ export class AppShowPage {
|
||||
}
|
||||
}
|
||||
|
||||
scrollToRequirements () {
|
||||
const el = document.getElementById('dependencies')
|
||||
if (!el) return
|
||||
let y = el.offsetTop
|
||||
return this.content.scrollToPoint(0, y, 1000)
|
||||
}
|
||||
|
||||
async fixDep (action: 'install' | 'update' | 'configure', id: string): Promise<void> {
|
||||
switch (action) {
|
||||
case 'install':
|
||||
@@ -175,22 +166,87 @@ export class AppShowPage {
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private setValues (pkg: PackageDataEntry): void {
|
||||
this.pkg = pkg
|
||||
this.installProgress = !isEmptyObject(pkg['install-progress']) ? this.packageLoadingService.transform(pkg['install-progress']) : undefined
|
||||
// we can safely ignore any current dependencies that are not defined in the service manifest
|
||||
this.currentDependencies = { }
|
||||
Object.entries(pkg.installed?.['current-dependencies'] || { }).forEach(([id, value]) => {
|
||||
if (pkg.manifest.dependencies[id]) {
|
||||
this.currentDependencies[id] = value
|
||||
}
|
||||
})
|
||||
if (pkg.installed?.status.main.status === PackageMainStatus.Running) {
|
||||
this.healthChecks = { ...pkg.installed.status.main.health }
|
||||
} else {
|
||||
private setValues (pkgs: { [id: string]: PackageDataEntry }): void {
|
||||
this.pkg = pkgs[this.pkgId]
|
||||
this.installProgress = !isEmptyObject(this.pkg['install-progress']) ? this.packageLoadingService.transform(this.pkg['install-progress']) : undefined
|
||||
this.statuses = renderPkgStatus(this.pkg)
|
||||
|
||||
if (!this.pkg.installed) {
|
||||
this.dependencies = { }
|
||||
this.healthChecks = { }
|
||||
} else {
|
||||
// ** dependencies
|
||||
Object.keys(this.pkg.installed['current-dependencies'] || { })
|
||||
.forEach(id => {
|
||||
// we can safely ignore any current dependencies that are not defined in the service manifest
|
||||
const manifestDep = this.pkg.manifest.dependencies[id]
|
||||
if (manifestDep) {
|
||||
let errorText = ''
|
||||
let spinnerColor = ''
|
||||
let actionText = 'View'
|
||||
let action: () => any = () => this.navCtrl.navigateForward(`/services/${id}`)
|
||||
|
||||
const error = this.pkg.installed.status['dependency-errors'][id] || null
|
||||
|
||||
if (error) {
|
||||
const localDep = pkgs[id]
|
||||
// health checks failed
|
||||
if ([DependencyErrorType.InterfaceHealthChecksFailed, DependencyErrorType.HealthChecksFailed].includes(error.type)) {
|
||||
errorText = 'Health Check Failed'
|
||||
// not fully installed (same as !localDep?.installed)
|
||||
} else if (error.type === DependencyErrorType.NotInstalled) {
|
||||
if (localDep) {
|
||||
errorText = localDep.state // 'Installing' | 'Removing'
|
||||
} else {
|
||||
errorText = 'Not Installed'
|
||||
actionText = 'Install'
|
||||
action = () => this.fixDep('install', id)
|
||||
}
|
||||
// incorrect version
|
||||
} else if (error.type === DependencyErrorType.IncorrectVersion) {
|
||||
if (localDep) {
|
||||
errorText = localDep.state // 'Updating' | 'Removing'
|
||||
} else {
|
||||
errorText = 'Incorrect Version'
|
||||
actionText = 'Update'
|
||||
action = () => this.fixDep('update', id)
|
||||
}
|
||||
// not running
|
||||
} else if (error.type === DependencyErrorType.NotRunning) {
|
||||
errorText = 'Not Running'
|
||||
actionText = 'Start'
|
||||
// config unsatisfied
|
||||
} else if (error.type === DependencyErrorType.ConfigUnsatisfied) {
|
||||
errorText = 'Config Not Satisfied'
|
||||
actionText = 'Auto Config'
|
||||
action = () => this.fixDep('configure', id)
|
||||
}
|
||||
|
||||
if (localDep && localDep.state !== PackageState.Installed) {
|
||||
spinnerColor = localDep.state === PackageState.Removing ? 'danger' : 'primary'
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.dependencies[id]) this.dependencies[id] = { } as any
|
||||
|
||||
const depInfo = this.pkg.installed['dependency-info'][id]
|
||||
|
||||
this.dependencies[id].title = depInfo.manifest.title
|
||||
this.dependencies[id].icon = depInfo.icon
|
||||
this.dependencies[id].version = manifestDep.version
|
||||
this.dependencies[id].errorText = errorText
|
||||
this.dependencies[id].actionText = actionText
|
||||
this.dependencies[id].spinnerColor = spinnerColor
|
||||
this.dependencies[id].action = action
|
||||
}
|
||||
})
|
||||
// ** health
|
||||
if (this.pkg.installed.status.main.status === PackageMainStatus.Running) {
|
||||
this.healthChecks = { ...this.pkg.installed.status.main.health }
|
||||
} else {
|
||||
this.healthChecks = { }
|
||||
}
|
||||
}
|
||||
this.statuses = renderPkgStatus(pkg)
|
||||
}
|
||||
|
||||
private async installDep (depId: string): Promise<void> {
|
||||
@@ -334,6 +390,16 @@ export class AppShowPage {
|
||||
}
|
||||
}
|
||||
|
||||
interface DependencyInfo {
|
||||
title: string
|
||||
icon: string
|
||||
version: string
|
||||
errorText: string
|
||||
spinnerColor: string
|
||||
actionText: string
|
||||
action: () => any
|
||||
}
|
||||
|
||||
interface Button {
|
||||
title: string
|
||||
icon: string
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ion-buttons slot="start">
|
||||
<pwa-back-button></pwa-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Listing</ion-title>
|
||||
<ion-title>Marketplace Listing</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ export function renderPkgStatus (pkg: PackageDataEntry): {
|
||||
dependency: DependencyStatus | null,
|
||||
health: HealthStatus | null
|
||||
} {
|
||||
console.log('PKGPKG', pkg)
|
||||
let primary: PrimaryStatus
|
||||
let dependency: DependencyStatus | null = null
|
||||
let health: HealthStatus | null = null
|
||||
|
||||
if (pkg.state === PackageState.Installed) {
|
||||
primary = pkg.installed.status.main.status as string as PrimaryStatus
|
||||
dependency = getDependencyStatus(pkg.installed)
|
||||
dependency = getDependencyStatus(pkg)
|
||||
health = getHealthStatus(pkg.installed.status)
|
||||
} else {
|
||||
primary = pkg.state as string as PrimaryStatus
|
||||
@@ -21,11 +22,11 @@ export function renderPkgStatus (pkg: PackageDataEntry): {
|
||||
return { primary, dependency, health }
|
||||
}
|
||||
|
||||
function getDependencyStatus (pkg: InstalledPackageDataEntry): DependencyStatus {
|
||||
console.log('pkg', pkg)
|
||||
if (isEmptyObject(pkg['current-dependencies'])) return null
|
||||
function getDependencyStatus (pkg: PackageDataEntry): DependencyStatus {
|
||||
const installed = pkg.installed
|
||||
if (isEmptyObject(installed['current-dependencies'])) return null
|
||||
|
||||
const pkgIds = Object.keys(pkg.status['dependency-errors'])
|
||||
const pkgIds = Object.keys(installed.status['dependency-errors'])
|
||||
|
||||
for (let pkgId of pkgIds) {
|
||||
if (pkg.manifest.dependencies[pkgId].critical) {
|
||||
@@ -92,8 +93,8 @@ export const PrimaryRendering: { [key: string]: StatusRendering } = {
|
||||
[PrimaryStatus.Installing]: { display: 'Installing', color: 'primary', showDots: true },
|
||||
[PrimaryStatus.Updating]: { display: 'Updating', color: 'primary', showDots: true },
|
||||
[PrimaryStatus.Removing]: { display: 'Removing', color: 'warning', showDots: true },
|
||||
[PrimaryStatus.Stopping]: { display: 'Stopping', color: 'dark', showDots: true },
|
||||
[PrimaryStatus.Stopped]: { display: 'Stopped', color: 'dark', showDots: false },
|
||||
[PrimaryStatus.Stopping]: { display: 'Stopping', color: 'dark-shade', showDots: true },
|
||||
[PrimaryStatus.Stopped]: { display: 'Stopped', color: 'dark-shade', showDots: false },
|
||||
[PrimaryStatus.BackingUp]: { display: 'Backing Up', color: 'warning', showDots: true },
|
||||
[PrimaryStatus.Restoring]: { display: 'Restoring', color: 'primary', showDots: true },
|
||||
[PrimaryStatus.Running]: { display: 'Running', color: 'success', showDots: false },
|
||||
|
||||
@@ -174,6 +174,15 @@ ion-button {
|
||||
box-shadow: 0 0 70px 70px black;
|
||||
}
|
||||
|
||||
.qr-modal {
|
||||
.modal-wrapper {
|
||||
width: 400px !important;
|
||||
height: 400px !important;
|
||||
top: unset !important;
|
||||
left: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-wrapper {
|
||||
position: absolute;
|
||||
height: 90% !important;
|
||||
@@ -236,11 +245,6 @@ ion-slides {
|
||||
}
|
||||
}
|
||||
|
||||
.qr-popover {
|
||||
--width: auto;
|
||||
--background: transparent !important;
|
||||
}
|
||||
|
||||
ion-item-divider {
|
||||
text-transform: uppercase;
|
||||
--padding-top: 24px;
|
||||
|
||||
Reference in New Issue
Block a user