mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
Merge pull request #2632 from Start9Labs/tables
feat: add mobile view for all the tables
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
.header-title span {
|
||||
position: relative;
|
||||
}
|
||||
.header-title span:before {
|
||||
.header-title span::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
||||
2
patch-db
2
patch-db
Submodule patch-db updated: 3dc11afd46...7aa53249f9
@@ -214,7 +214,7 @@ body {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.loading-dots:after {
|
||||
.loading-dots::after {
|
||||
content: '...';
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
@@ -303,4 +303,4 @@ h5,
|
||||
h6,
|
||||
hr {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
tui-root._mobile &:after {
|
||||
tui-root._mobile &::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ tui-dialog {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
tui-opt-group[data-label^='⚠️']:before {
|
||||
tui-opt-group[data-label^='⚠️']::before {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
@include transition(opacity);
|
||||
|
||||
content: '';
|
||||
|
||||
@@ -52,7 +52,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host-context(tui-root._mobile) *:before {
|
||||
:host-context(tui-root._mobile) *::before {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -1,44 +1,34 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { TuiDialogService, TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiFadeModule } from '@taiga-ui/experimental'
|
||||
import { BackupsCreateService } from './services/create.service'
|
||||
import { BackupsRestoreService } from './services/restore.service'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { BackupsUpcomingComponent } from './components/upcoming.component'
|
||||
import { TARGETS } from './modals/targets.component'
|
||||
import { HISTORY } from './modals/history.component'
|
||||
import { JOBS } from './modals/jobs.component'
|
||||
import { TARGETS } from './modals/targets.component'
|
||||
import { BackupsCreateService } from './services/create.service'
|
||||
import { BackupsRestoreService } from './services/restore.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section>
|
||||
<h3 class="g-title">Options</h3>
|
||||
<button
|
||||
*ngFor="let option of options"
|
||||
class="g-action"
|
||||
(click)="option.action()"
|
||||
>
|
||||
<tui-svg [src]="option.icon"></tui-svg>
|
||||
<div>
|
||||
<strong>{{ option.name }}</strong>
|
||||
<div>{{ option.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
@for (option of options; track $index) {
|
||||
<button class="g-action" (click)="option.action()">
|
||||
<tui-icon [icon]="option.icon" />
|
||||
<div>
|
||||
<strong>{{ option.name }}</strong>
|
||||
<div>{{ option.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
<h3 class="g-title">Upcoming Jobs</h3>
|
||||
<div tuiFade class="g-hidden-scrollbar">
|
||||
<table backupsUpcoming class="g-table"></table>
|
||||
</div>
|
||||
<table backupsUpcoming class="g-table"></table>
|
||||
`,
|
||||
host: { class: 'g-page' },
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiSvgModule,
|
||||
TuiFadeModule,
|
||||
BackupsUpcomingComponent,
|
||||
],
|
||||
imports: [BackupsUpcomingComponent, TuiIconModule],
|
||||
})
|
||||
export default class BackupsComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
@@ -25,51 +25,94 @@ import { UnknownDisk } from 'src/app/services/api/api.types'
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let disk of backupsPhysical; else: loading; empty: blank">
|
||||
<td>
|
||||
{{ disk.vendor || 'unknown make' }},
|
||||
{{ disk.model || 'unknown model' }}
|
||||
</td>
|
||||
<td>{{ disk.label }}</td>
|
||||
<td>{{ disk.capacity | convertBytes }}</td>
|
||||
<td>{{ disk.used ? (disk.used | convertBytes) : 'Unknown' }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiButton
|
||||
size="xs"
|
||||
icon="tuiIconPlus"
|
||||
(click)="add.emit(disk)"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<ng-template #loading>
|
||||
@for (disk of backupsPhysical; track $index) {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div class="tui-skeleton">Loading</div>
|
||||
<td class="model">
|
||||
{{ disk.vendor || 'unknown make' }},
|
||||
{{ disk.model || 'unknown model' }}
|
||||
</td>
|
||||
<td class="title">{{ disk.label }}</td>
|
||||
<td class="capacity">{{ disk.capacity | convertBytes }}</td>
|
||||
<td class="used">
|
||||
{{ disk.used ? (disk.used | convertBytes) : 'Unknown' }}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
tuiButton
|
||||
size="xs"
|
||||
iconLeft="tuiIconPlus"
|
||||
(click)="add.emit(disk)"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template #blank>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
To add a new physical backup target, connect the drive and click
|
||||
refresh.
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
} @empty {
|
||||
@if (backupsPhysical) {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
To add a new physical backup target, connect the drive and click
|
||||
refresh.
|
||||
</td>
|
||||
</tr>
|
||||
} @else {
|
||||
<tr>
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
`,
|
||||
styles: `
|
||||
:host-context(tui-root._mobile) {
|
||||
tr {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
td:only-child {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.model {
|
||||
order: 1;
|
||||
white-space: nowrap;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
|
||||
.actions {
|
||||
order: 2;
|
||||
padding: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.title {
|
||||
order: 3;
|
||||
grid-column: span 2;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.capacity {
|
||||
order: 4;
|
||||
|
||||
&::before {
|
||||
content: 'Capacity: ';
|
||||
}
|
||||
}
|
||||
|
||||
.used {
|
||||
order: 5;
|
||||
text-align: right;
|
||||
|
||||
&::before {
|
||||
content: 'Used: ';
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiForModule,
|
||||
TuiSvgModule,
|
||||
TuiButtonModule,
|
||||
UnitConversionPipesModule,
|
||||
],
|
||||
imports: [TuiButtonModule, UnitConversionPipesModule],
|
||||
})
|
||||
export class BackupsPhysicalComponent {
|
||||
@Input()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -7,13 +6,8 @@ import {
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { TuiForModule } from '@taiga-ui/cdk'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { filter, map, Subject, switchMap } from 'rxjs'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
@@ -32,69 +26,114 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let target of backupsTargets; else: loading; empty: blank">
|
||||
<td>{{ target.name }}</td>
|
||||
<td>
|
||||
<tui-svg [src]="target.type | getBackupIcon"></tui-svg>
|
||||
{{ target.type | titlecase }}
|
||||
</td>
|
||||
<td>
|
||||
<tui-svg
|
||||
[src]="target.mountable ? 'tuiIconCheck' : 'tuiIconClose'"
|
||||
[style.color]="
|
||||
target.mountable ? 'var(--tui-positive)' : 'var(--tui-negative)'
|
||||
"
|
||||
></tui-svg>
|
||||
</td>
|
||||
<td>{{ target.path }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconEdit2"
|
||||
(click)="update.emit(target)"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconTrash2"
|
||||
(click)="delete$.next(target.id)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<ng-template #loading>
|
||||
<tr *ngFor="let i of ['', '']">
|
||||
<td colspan="5">
|
||||
<div class="tui-skeleton">Loading</div>
|
||||
@for (target of backupsTargets; track $index) {
|
||||
<tr>
|
||||
<td class="title">{{ target.name }}</td>
|
||||
<td class="type">
|
||||
<tui-icon [icon]="target.type | getBackupIcon" />
|
||||
{{ target.type }}
|
||||
</td>
|
||||
<td class="available">
|
||||
<tui-icon
|
||||
[icon]="target.mountable ? 'tuiIconCheck' : 'tuiIconClose'"
|
||||
[class]="target.mountable ? 'g-success' : 'g-error'"
|
||||
/>
|
||||
</td>
|
||||
<td class="path">{{ target.path }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconEdit2"
|
||||
(click)="update.emit(target)"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconTrash2"
|
||||
(click)="delete$.next(target.id)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template #blank>
|
||||
<tr><td colspan="5">No saved backup targets.</td></tr>
|
||||
</ng-template>
|
||||
} @empty {
|
||||
@if (backupsTargets) {
|
||||
<tr><td colspan="5">No saved backup targets.</td></tr>
|
||||
} @else {
|
||||
@for (i of ['', '']; track $index) {
|
||||
<tr>
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
`,
|
||||
styles: `
|
||||
tui-icon {
|
||||
font-size: 1rem;
|
||||
vertical-align: sub;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
tr {
|
||||
grid-template-columns: 1.5rem 2fr 1fr;
|
||||
}
|
||||
|
||||
td:only-child {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.type {
|
||||
order: 1;
|
||||
text-transform: capitalize;
|
||||
color: var(--tui-text-02);
|
||||
grid-column: span 3;
|
||||
|
||||
tui-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.available {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.title {
|
||||
order: 3;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.actions {
|
||||
order: 4;
|
||||
padding: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.path {
|
||||
order: 5;
|
||||
color: var(--tui-text-03);
|
||||
grid-column: span 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiForModule,
|
||||
TuiSvgModule,
|
||||
TuiButtonModule,
|
||||
GetBackupIconPipe,
|
||||
],
|
||||
imports: [TuiButtonModule, GetBackupIconPipe, TuiIconModule],
|
||||
})
|
||||
export class BackupsTargetsComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
readonly delete$ = new Subject<string>()
|
||||
readonly update$ = new Subject<BackupTarget>()
|
||||
|
||||
@Input()
|
||||
backupsTargets: readonly BackupTarget[] | null = null
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { TuiForModule } from '@taiga-ui/cdk'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { CronJob } from 'cron'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { from, map } from 'rxjs'
|
||||
import { CronJob } from 'cron'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||
|
||||
@Component({
|
||||
@@ -20,58 +20,101 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||
<th>Packages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody *ngIf="current$ | async as current">
|
||||
<tr *ngFor="let job of upcoming$ | async; else: loading; empty: blank">
|
||||
<td>
|
||||
<span
|
||||
*ngIf="current.id === job.id; else notRunning"
|
||||
[style.color]="'var(--tui-positive)'"
|
||||
>
|
||||
Running
|
||||
</span>
|
||||
<ng-template #notRunning>
|
||||
{{ job.next | date: 'MMM d, y, h:mm a' }}
|
||||
</ng-template>
|
||||
</td>
|
||||
<td>{{ job.name }}</td>
|
||||
<td>
|
||||
<tui-svg [src]="job.target.type | getBackupIcon"></tui-svg>
|
||||
{{ job.target.name }}
|
||||
</td>
|
||||
<td>Packages: {{ job.packageIds.length }}</td>
|
||||
</tr>
|
||||
<ng-template #blank>
|
||||
<tr><td colspan="5">You have no active or upcoming backup jobs</td></tr>
|
||||
</ng-template>
|
||||
<ng-template #loading>
|
||||
<tr *ngFor="let row of ['', '']">
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</tbody>
|
||||
@if (current(); as current) {
|
||||
<tbody>
|
||||
@for (job of upcoming(); track $index) {
|
||||
<tr>
|
||||
<td class="date">
|
||||
@if (current.id === job.id) {
|
||||
<span [style.color]="'var(--tui-positive)'">Running</span>
|
||||
} @else {
|
||||
{{ job.next | date: 'medium' }}
|
||||
}
|
||||
</td>
|
||||
<td class="name">{{ job.name }}</td>
|
||||
<td>
|
||||
<tui-icon [icon]="job.target.type | getBackupIcon" />
|
||||
{{ job.target.name }}
|
||||
</td>
|
||||
<td class="packages">Packages: {{ job.packageIds.length }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
@if (upcoming()) {
|
||||
<tr>
|
||||
<td colspan="5">You have no active or upcoming backup jobs</td>
|
||||
</tr>
|
||||
} @else {
|
||||
@for (row of ['', '']; track $index) {
|
||||
<tr>
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.date {
|
||||
order: 1;
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.name {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
tui-icon {
|
||||
font-size: 1rem;
|
||||
vertical-align: sub;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
.date {
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
|
||||
.name {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.packages {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiForModule, TuiSvgModule, GetBackupIconPipe],
|
||||
imports: [GetBackupIconPipe, DatePipe, TuiIconModule],
|
||||
})
|
||||
export class BackupsUpcomingComponent {
|
||||
readonly current$ = inject(PatchDB<DataModel>)
|
||||
.watch$('serverInfo', 'statusInfo', 'currentBackup', 'job')
|
||||
.pipe(map(job => job || {}))
|
||||
readonly current = toSignal(
|
||||
inject(PatchDB<DataModel>)
|
||||
.watch$('serverInfo', 'statusInfo', 'currentBackup', 'job')
|
||||
.pipe(map(job => job || {})),
|
||||
)
|
||||
|
||||
readonly upcoming$ = from(inject(ApiService).getBackupJobs({})).pipe(
|
||||
map(jobs =>
|
||||
jobs
|
||||
.map(job => {
|
||||
const nextDate = new CronJob(job.cron, () => {}).nextDate()
|
||||
readonly upcoming = toSignal(
|
||||
from(inject(ApiService).getBackupJobs({})).pipe(
|
||||
map(jobs =>
|
||||
jobs
|
||||
.map(job => {
|
||||
const nextDate = new CronJob(job.cron, () => {}).nextDate()
|
||||
|
||||
return {
|
||||
...job,
|
||||
next: nextDate.toISO(),
|
||||
diff: nextDate.diffNow().milliseconds,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.diff - b.diff),
|
||||
return {
|
||||
...job,
|
||||
next: nextDate.toISO(),
|
||||
diff: nextDate.diffNow().milliseconds,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.diff - b.diff),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { ALWAYS_FALSE_HANDLER, ALWAYS_TRUE_HANDLER } from '@taiga-ui/cdk'
|
||||
import { TuiDialogService, TuiLinkModule } from '@taiga-ui/core'
|
||||
import {
|
||||
ALWAYS_FALSE_HANDLER,
|
||||
ALWAYS_TRUE_HANDLER,
|
||||
TuiForModule,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { TuiDialogService, TuiLinkModule, TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiFadeModule } from '@taiga-ui/experimental'
|
||||
import { TuiCheckboxModule } from '@taiga-ui/kit'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
TuiButtonModule,
|
||||
TuiCheckboxModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { REPORT } from 'src/app/components/report.component'
|
||||
import { BackupRun } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { REPORT } from 'src/app/components/report.component'
|
||||
import { DurationPipe } from '../pipes/duration.pipe'
|
||||
import { HasErrorPipe } from '../pipes/has-error.pipe'
|
||||
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||
import { HasErrorPipe } from '../pipes/has-error.pipe'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *ngIf="loading$ | async"></ng-container>
|
||||
<h3 class="g-title">
|
||||
Past Events
|
||||
<button
|
||||
@@ -33,83 +35,133 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||
Delete Selected
|
||||
</button>
|
||||
</h3>
|
||||
<div tuiFade class="g-hidden-scrollbar">
|
||||
<table class="g-table">
|
||||
<thead>
|
||||
<table class="g-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
size="s"
|
||||
tuiCheckbox
|
||||
[disabled]="!selected.length"
|
||||
[ngModel]="all"
|
||||
(ngModelChange)="toggle()"
|
||||
/>
|
||||
</th>
|
||||
<th>Started At</th>
|
||||
<th>Duration</th>
|
||||
<th>Job</th>
|
||||
<th>Result</th>
|
||||
<th>Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (run of runs(); track $index) {
|
||||
<tr>
|
||||
<th>
|
||||
<tui-checkbox
|
||||
[disabled]="!selected.length"
|
||||
[ngModel]="all"
|
||||
(ngModelChange)="toggle()"
|
||||
></tui-checkbox>
|
||||
</th>
|
||||
<th>Started At</th>
|
||||
<th>Duration</th>
|
||||
<th>Result</th>
|
||||
<th>Job</th>
|
||||
<th>Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="
|
||||
let run of runs;
|
||||
let index = index;
|
||||
else: loading;
|
||||
empty: blank
|
||||
"
|
||||
[style.background]="selected[index] ? 'var(--tui-clear)' : ''"
|
||||
>
|
||||
<td><tui-checkbox [(ngModel)]="selected[index]"></tui-checkbox></td>
|
||||
<td>{{ run.startedAt | date: 'medium' }}</td>
|
||||
<td>{{ run.startedAt | duration: run.completedAt }} Minutes</td>
|
||||
<td>
|
||||
<tui-svg
|
||||
*ngIf="run.report | hasError; else noError"
|
||||
src="tuiIconClose"
|
||||
[style.color]="'var(--tui-negative)'"
|
||||
></tui-svg>
|
||||
<ng-template #noError>
|
||||
<tui-svg
|
||||
src="tuiIconCheck"
|
||||
[style.color]="'var(--tui-positive)'"
|
||||
></tui-svg>
|
||||
</ng-template>
|
||||
<td class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
tuiCheckbox
|
||||
size="s"
|
||||
[(ngModel)]="selected[$index]"
|
||||
/>
|
||||
</td>
|
||||
<td class="date">{{ run.startedAt | date: 'medium' }}</td>
|
||||
<td class="duration">
|
||||
{{ run.startedAt | duration: run.completedAt }} minutes
|
||||
</td>
|
||||
<td class="title">{{ run.job.name || 'No job' }}</td>
|
||||
<td class="result">
|
||||
@if (run.report | hasError) {
|
||||
<tui-icon icon="tuiIconClose" class="g-error" />
|
||||
} @else {
|
||||
<tui-icon icon="tuiIconCheck" class="g-success" />
|
||||
}
|
||||
<button tuiLink (click)="showReport(run)">Report</button>
|
||||
</td>
|
||||
<td>{{ run.job.name || 'No job' }}</td>
|
||||
<td>
|
||||
<tui-svg [src]="run.job.target.type | getBackupIcon"></tui-svg>
|
||||
<td [style.grid-column]="'span 2'">
|
||||
<tui-icon [icon]="run.job.target.type | getBackupIcon" />
|
||||
{{ run.job.target.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<ng-template #loading>
|
||||
<tr *ngFor="let row of ['', '', '']">
|
||||
<td colspan="6"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template #blank>
|
||||
} @empty {
|
||||
@if (runs()) {
|
||||
<tr><td colspan="6">No backups have been run yet.</td></tr>
|
||||
</ng-template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
@for (row of ['', '']; track $index) {
|
||||
<tr>
|
||||
<td colspan="6"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
styles: `
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
tui-icon {
|
||||
font-size: 1rem;
|
||||
vertical-align: sub;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[tuiCheckbox] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
tr {
|
||||
grid-template-columns: 1fr 7rem;
|
||||
}
|
||||
|
||||
td:only-child {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
@include fullsize();
|
||||
|
||||
[tuiCheckbox] {
|
||||
@include fullsize();
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.date,
|
||||
.duration {
|
||||
order: 1;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
|
||||
.duration,
|
||||
.result {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiForModule,
|
||||
TuiButtonModule,
|
||||
TuiCheckboxModule,
|
||||
TuiSvgModule,
|
||||
TuiIconModule,
|
||||
TuiLinkModule,
|
||||
TuiFadeModule,
|
||||
DurationPipe,
|
||||
HasErrorPipe,
|
||||
GetBackupIconPipe,
|
||||
TuiCheckboxModule,
|
||||
],
|
||||
})
|
||||
export class BackupsHistoryModal {
|
||||
@@ -118,9 +170,7 @@ export class BackupsHistoryModal {
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
|
||||
readonly loading$ = new BehaviorSubject(true)
|
||||
|
||||
runs: BackupRun[] | null = null
|
||||
runs = signal<BackupRun[] | null>(null)
|
||||
selected: boolean[] = []
|
||||
|
||||
get all(): boolean | null {
|
||||
@@ -143,13 +193,11 @@ export class BackupsHistoryModal {
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.runs = await this.api.getBackupRuns({})
|
||||
this.selected = this.runs.map(ALWAYS_FALSE_HANDLER)
|
||||
this.runs.set(await this.api.getBackupRuns({}))
|
||||
this.selected = this.runs()?.map(ALWAYS_FALSE_HANDLER) || []
|
||||
} catch (e: any) {
|
||||
this.runs = []
|
||||
this.runs.set([])
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,12 +205,12 @@ export class BackupsHistoryModal {
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
const ids = this.selected
|
||||
.filter(Boolean)
|
||||
.map((_, i) => this.runs?.[i].id || '')
|
||||
.map((_, i) => this.runs()?.[i].id || '')
|
||||
|
||||
try {
|
||||
await this.api.deleteBackupRuns({ ids })
|
||||
this.runs = this.runs?.filter(r => !ids.includes(r.id)) || []
|
||||
this.selected = this.runs.map(ALWAYS_FALSE_HANDLER)
|
||||
this.runs.set(this.runs()?.filter(r => !ids.includes(r.id)) || [])
|
||||
this.selected = this.runs()?.map(ALWAYS_FALSE_HANDLER) || []
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, inject, OnInit } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiForModule } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiNotificationModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiFadeModule } from '@taiga-ui/experimental'
|
||||
import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { BehaviorSubject, filter } from 'rxjs'
|
||||
import { BackupJob } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { BackupJobBuilder } from '../utils/job-builder'
|
||||
import { ToHumanCronPipe } from '../pipes/to-human-cron.pipe'
|
||||
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
|
||||
import { ToHumanCronPipe } from '../pipes/to-human-cron.pipe'
|
||||
import { BackupJobBuilder } from '../utils/job-builder'
|
||||
import { EDIT } from './edit.component'
|
||||
|
||||
@Component({
|
||||
@@ -39,27 +36,27 @@ import { EDIT } from './edit.component'
|
||||
Create New Job
|
||||
</button>
|
||||
</h3>
|
||||
<div class="g-hidden-scrollbar" tuiFade>
|
||||
<table class="g-table">
|
||||
<thead>
|
||||
<table class="g-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Target</th>
|
||||
<th>Packages</th>
|
||||
<th>Schedule</th>
|
||||
<th [style.width.rem]="3.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (job of jobs; track $index) {
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Target</th>
|
||||
<th>Packages</th>
|
||||
<th>Schedule</th>
|
||||
<th [style.width.rem]="3.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let job of jobs || null; else: loading; empty: blank">
|
||||
<td>{{ job.name }}</td>
|
||||
<td>
|
||||
<tui-svg [src]="job.target.type | getBackupIcon"></tui-svg>
|
||||
<td class="title">{{ job.name }}</td>
|
||||
<td class="target">
|
||||
<tui-icon [icon]="job.target.type | getBackupIcon" />
|
||||
{{ job.target.name }}
|
||||
</td>
|
||||
<td>Packages: {{ job.packageIds.length }}</td>
|
||||
<td>{{ (job.cron | toHumanCron).message }}</td>
|
||||
<td>
|
||||
<td class="packages">Packages: {{ job.packageIds.length }}</td>
|
||||
<td class="schedule">{{ (job.cron | toHumanCron).message }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
@@ -76,26 +73,68 @@ import { EDIT } from './edit.component'
|
||||
></button>
|
||||
</td>
|
||||
</tr>
|
||||
<ng-template #loading>
|
||||
<tr *ngFor="let i of ['', '']">
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template #blank>
|
||||
} @empty {
|
||||
@if (jobs) {
|
||||
<tr><td colspan="5">No jobs found.</td></tr>
|
||||
</ng-template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
@for (i of ['', '']; track $index) {
|
||||
<tr>
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
styles: `
|
||||
tui-icon {
|
||||
font-size: 1rem;
|
||||
vertical-align: sub;
|
||||
margin-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
tr {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
td:only-child {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.title {
|
||||
order: 1;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.actions {
|
||||
order: 2;
|
||||
padding: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.target {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.packages {
|
||||
order: 4;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.schedule {
|
||||
order: 5;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
}
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiForModule,
|
||||
TuiNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiSvgModule,
|
||||
TuiFadeModule,
|
||||
TuiIconModule,
|
||||
ToHumanCronPipe,
|
||||
GetBackupIconPipe,
|
||||
],
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, inject, OnInit } from '@angular/core'
|
||||
import { Component, inject, OnInit, signal } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiFadeModule } from '@taiga-ui/experimental'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import {
|
||||
cifsSpec,
|
||||
diskBackupTargetSpec,
|
||||
dropboxSpec,
|
||||
googleDriveSpec,
|
||||
remoteBackupTargetSpec,
|
||||
} from '../types/target'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import {
|
||||
BackupTarget,
|
||||
BackupTargetType,
|
||||
@@ -23,13 +13,21 @@ import {
|
||||
UnknownDisk,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { BackupConfig } from '../types/backup-config'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { BackupsPhysicalComponent } from '../components/physical.component'
|
||||
import { BackupsTargetsComponent } from '../components/targets.component'
|
||||
import { BackupConfig } from '../types/backup-config'
|
||||
import {
|
||||
cifsSpec,
|
||||
diskBackupTargetSpec,
|
||||
dropboxSpec,
|
||||
googleDriveSpec,
|
||||
remoteBackupTargetSpec,
|
||||
} from '../types/target'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *ngIf="loading$ | async"></ng-container>
|
||||
<tui-notification>
|
||||
Backup targets are physical or virtual locations for storing encrypted
|
||||
backups. They can be physical drives plugged into your server, shared
|
||||
@@ -45,31 +43,32 @@ import { BackupsTargetsComponent } from '../components/targets.component'
|
||||
</tui-notification>
|
||||
<h3 class="g-title">
|
||||
Unknown Physical Drives
|
||||
<button tuiButton size="s" icon="tuiIconRefreshCw" (click)="refresh()">
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
iconLeft="tuiIconRefreshCw"
|
||||
(click)="refresh()"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</h3>
|
||||
<div class="g-hidden-scrollbar" tuiFade>
|
||||
<table
|
||||
class="g-table"
|
||||
[backupsPhysical]="targets?.unknownDisks || null"
|
||||
(add)="addPhysical($event)"
|
||||
></table>
|
||||
</div>
|
||||
<table
|
||||
class="g-table"
|
||||
[backupsPhysical]="targets()?.unknownDisks || null"
|
||||
(add)="addPhysical($event)"
|
||||
></table>
|
||||
<h3 class="g-title">
|
||||
Saved Targets
|
||||
<button tuiButton size="s" icon="tuiIconPlus" (click)="addRemote()">
|
||||
<button tuiButton size="s" iconLeft="tuiIconPlus" (click)="addRemote()">
|
||||
Add Target
|
||||
</button>
|
||||
</h3>
|
||||
<div class="g-hidden-scrollbar" tuiFade>
|
||||
<table
|
||||
class="g-table"
|
||||
[backupsTargets]="targets?.saved || null"
|
||||
(delete)="onDelete($event)"
|
||||
(update)="onUpdate($event)"
|
||||
></table>
|
||||
</div>
|
||||
<table
|
||||
class="g-table"
|
||||
[backupsTargets]="targets()?.saved || null"
|
||||
(delete)="onDelete($event)"
|
||||
(update)="onUpdate($event)"
|
||||
></table>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [
|
||||
@@ -78,7 +77,6 @@ import { BackupsTargetsComponent } from '../components/targets.component'
|
||||
TuiButtonModule,
|
||||
BackupsPhysicalComponent,
|
||||
BackupsTargetsComponent,
|
||||
TuiFadeModule,
|
||||
],
|
||||
})
|
||||
export class BackupsTargetsModal implements OnInit {
|
||||
@@ -87,25 +85,20 @@ export class BackupsTargetsModal implements OnInit {
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
|
||||
readonly loading$ = new BehaviorSubject(true)
|
||||
|
||||
targets?: RR.GetBackupTargetsRes
|
||||
targets = signal<RR.GetBackupTargetsRes | null>(null)
|
||||
|
||||
ngOnInit() {
|
||||
this.refresh()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading$.next(true)
|
||||
this.targets = undefined
|
||||
this.targets.set(null)
|
||||
|
||||
try {
|
||||
this.targets = await this.api.getBackupTargets({})
|
||||
this.targets.set(await this.api.getBackupTargets({}))
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
this.targets = { unknownDisks: [], saved: [] }
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
this.targets.set({ unknownDisks: [], saved: [] })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +107,7 @@ export class BackupsTargetsModal implements OnInit {
|
||||
|
||||
try {
|
||||
await this.api.removeBackupTarget({ id })
|
||||
this.setTargets(this.targets?.saved.filter(a => a.id !== id))
|
||||
this.setTargets(this.targets()?.saved.filter(a => a.id !== id))
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
@@ -158,8 +151,8 @@ export class BackupsTargetsModal implements OnInit {
|
||||
...value,
|
||||
}).then(response => {
|
||||
this.setTargets(
|
||||
this.targets?.saved.concat(response),
|
||||
this.targets?.unknownDisks.filter(a => a !== disk),
|
||||
this.targets()?.saved.concat(response),
|
||||
this.targets()?.unknownDisks.filter(a => a !== disk),
|
||||
)
|
||||
return true
|
||||
}),
|
||||
@@ -221,10 +214,10 @@ export class BackupsTargetsModal implements OnInit {
|
||||
}
|
||||
|
||||
private setTargets(
|
||||
saved: BackupTarget[] = this.targets?.saved || [],
|
||||
unknownDisks: UnknownDisk[] = this.targets?.unknownDisks || [],
|
||||
saved: BackupTarget[] = this.targets()?.saved || [],
|
||||
unknownDisks: UnknownDisk[] = this.targets()?.unknownDisks || [],
|
||||
) {
|
||||
this.targets = { unknownDisks, saved }
|
||||
this.targets.set({ unknownDisks, saved })
|
||||
}
|
||||
|
||||
private async getSpec(target: BackupTarget) {
|
||||
|
||||
@@ -45,7 +45,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.5rem;
|
||||
@@ -53,7 +53,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0.25rem;
|
||||
|
||||
@@ -131,7 +131,7 @@ import { TimeService } from 'src/app/services/time.service'
|
||||
color: var(--tui-text-01);
|
||||
padding-top: 0.4rem;
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
content: attr(data-unit);
|
||||
font-size: 0.5rem;
|
||||
font-weight: normal;
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
import { TuiLinkModule, TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiLinkModule } from '@taiga-ui/core'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { TuiLineClampModule } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { first, Observable } from 'rxjs'
|
||||
@@ -20,22 +21,24 @@ import { toRouterLink } from 'src/app/utils/to-router-link'
|
||||
@Component({
|
||||
selector: '[notificationItem]',
|
||||
template: `
|
||||
<td [style.padding-top.rem]="0.4"><ng-content /></td>
|
||||
<td>{{ notificationItem.createdAt | date: 'MMM d, y, h:mm a' }}</td>
|
||||
<td [style.color]="color">
|
||||
<tui-svg [src]="icon" />
|
||||
<td class="checkbox"><ng-content /></td>
|
||||
<td class="date">
|
||||
{{ notificationItem.createdAt | date: 'medium' }}
|
||||
</td>
|
||||
<td class="title" [style.color]="color">
|
||||
<tui-icon [icon]="icon" [style.font-size.rem]="1" />
|
||||
{{ notificationItem.title }}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
*ngIf="manifest$ | async as manifest; else na"
|
||||
[routerLink]="getLink(manifest.id)"
|
||||
>
|
||||
{{ manifest.title }}
|
||||
</a>
|
||||
<ng-template #na>N/A</ng-template>
|
||||
<td class="service">
|
||||
@if (manifest$ | async; as manifest) {
|
||||
<a tuiLink [routerLink]="getLink(manifest.id)">
|
||||
{{ manifest.title }}
|
||||
</a>
|
||||
} @else {
|
||||
N/A
|
||||
}
|
||||
</td>
|
||||
<td [style.padding-bottom.rem]="0.5">
|
||||
<td class="content">
|
||||
<tui-line-clamp
|
||||
style="pointer-events: none"
|
||||
[linesLimit]="4"
|
||||
@@ -43,20 +46,16 @@ import { toRouterLink } from 'src/app/utils/to-router-link'
|
||||
[content]="notificationItem.message"
|
||||
(overflownChange)="overflow = $event"
|
||||
/>
|
||||
<button
|
||||
*ngIf="overflow"
|
||||
tuiLink
|
||||
(click)="service.viewFull(notificationItem)"
|
||||
>
|
||||
View Full
|
||||
</button>
|
||||
<button
|
||||
*ngIf="notificationItem.code === 1"
|
||||
tuiLink
|
||||
(click)="service.viewReport(notificationItem)"
|
||||
>
|
||||
View Report
|
||||
</button>
|
||||
@if (overflow) {
|
||||
<button tuiLink (click)="service.viewFull(notificationItem)">
|
||||
View Full
|
||||
</button>
|
||||
}
|
||||
@if (notificationItem.code === 1) {
|
||||
<button tuiLink (click)="service.viewReport(notificationItem)">
|
||||
View Report
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -65,21 +64,58 @@ import { toRouterLink } from 'src/app/utils/to-router-link'
|
||||
'[class._new]': '!notificationItem.read',
|
||||
},
|
||||
styles: `
|
||||
:host._new {
|
||||
background: var(--tui-clear);
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
&._new {
|
||||
background: var(--tui-clear) !important;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.25rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
.checkbox {
|
||||
@include fullsize();
|
||||
}
|
||||
|
||||
.date {
|
||||
order: 1;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.service:not(:has(a)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
TuiLineClampModule,
|
||||
TuiSvgModule,
|
||||
TuiLinkModule,
|
||||
TuiIconModule,
|
||||
],
|
||||
})
|
||||
export class NotificationItemComponent {
|
||||
|
||||
@@ -66,6 +66,14 @@ import { NotificationItemComponent } from './item.component'
|
||||
}
|
||||
</tbody>
|
||||
`,
|
||||
styles: `
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host-context(tui-root._mobile) input {
|
||||
@include fullsize();
|
||||
opacity: 0;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
|
||||
@@ -17,7 +17,6 @@ import { Domain } from 'src/app/services/patch-db/data-model'
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Added</th>
|
||||
<th>DDNS Provider</th>
|
||||
<th>Network Strategy</th>
|
||||
<th>Used By</th>
|
||||
@@ -25,36 +24,87 @@ import { Domain } from 'src/app/services/patch-db/data-model'
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let domain of domains">
|
||||
<td>{{ domain.value }}</td>
|
||||
<td>{{ domain.createdAt | date: 'short' }}</td>
|
||||
<td>{{ domain.provider }}</td>
|
||||
<td>{{ getStrategy(domain) }}</td>
|
||||
<td>
|
||||
<button
|
||||
*ngIf="domain.usedBy.length as qty; else unused"
|
||||
tuiLink
|
||||
(click)="onUsedBy(domain)"
|
||||
>
|
||||
Interfaces: {{ qty }}
|
||||
</button>
|
||||
<ng-template #unused>N/A</ng-template>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconTrash2"
|
||||
[style.display]="'flex'"
|
||||
(click)="delete.emit(domain)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@for (domain of domains; track $index) {
|
||||
<tr *ngFor="let domain of domains">
|
||||
<td class="title">{{ domain.value }}</td>
|
||||
<td class="provider">{{ domain.provider }}</td>
|
||||
<td class="strategy">{{ getStrategy(domain) }}</td>
|
||||
<td class="used">
|
||||
@if (domain.usedBy.length; as qty) {
|
||||
<button tuiLink (click)="onUsedBy(domain)">
|
||||
Used by: {{ qty }}
|
||||
</button>
|
||||
} @else {
|
||||
N/A
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconTrash2"
|
||||
(click)="delete.emit(domain)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="6">No domains</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
`,
|
||||
styles: `
|
||||
:host-context(tui-root._mobile) {
|
||||
tr {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
|
||||
td:only-child {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.title {
|
||||
order: 1;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.actions {
|
||||
order: 2;
|
||||
padding: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.strategy {
|
||||
order: 3;
|
||||
grid-column: span 2;
|
||||
|
||||
&::before {
|
||||
content: 'Strategy: ';
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
}
|
||||
|
||||
.provider {
|
||||
order: 4;
|
||||
|
||||
&::before {
|
||||
content: 'DDNS: ';
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
}
|
||||
|
||||
.used {
|
||||
order: 5;
|
||||
text-align: right;
|
||||
|
||||
&:not(:has(button)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, TuiButtonModule, TuiLinkModule],
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiDataListModule,
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiDropdownModule,
|
||||
TuiHostedDropdownModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import {
|
||||
FormComponent,
|
||||
FormContext,
|
||||
} from 'src/app/routes/portal/components/form.component'
|
||||
import { Proxy } from 'src/app/services/patch-db/data-model'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { DELETE_OPTIONS, ProxyUpdate } from './constants'
|
||||
import { CB } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'proxies-menu',
|
||||
template: `
|
||||
<tui-hosted-dropdown
|
||||
style="float: right"
|
||||
tuiDropdownAlign="left"
|
||||
[sided]="true"
|
||||
[content]="dropdown"
|
||||
>
|
||||
<button
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
size="s"
|
||||
iconLeft="tuiIconMoreHorizontal"
|
||||
></button>
|
||||
</tui-hosted-dropdown>
|
||||
<ng-template #dropdown>
|
||||
<tui-data-list>
|
||||
<button tuiOption (click)="rename()">Rename</button>
|
||||
<tui-opt-group>
|
||||
<button tuiOption (click)="delete()">Delete</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
`,
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiButtonModule,
|
||||
TuiDataListModule,
|
||||
TuiDropdownModule,
|
||||
TuiHostedDropdownModule,
|
||||
],
|
||||
})
|
||||
export class ProxiesMenuComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
|
||||
@Input({ required: true }) proxy!: Proxy
|
||||
|
||||
delete() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, DELETE_OPTIONS)
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.deleteProxy({ id: this.proxy.id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async rename() {
|
||||
const spec = { name: 'Name', required: { default: this.proxy.name } }
|
||||
const name = await CB.Value.text(spec).build({} as any)
|
||||
const options: Partial<TuiDialogOptions<FormContext<{ name: string }>>> = {
|
||||
label: `Rename ${this.proxy.name}`,
|
||||
data: {
|
||||
spec: { name },
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: value => this.update(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
this.formDialog.open(FormComponent, options)
|
||||
}
|
||||
|
||||
private async update(value: ProxyUpdate): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.updateProxy(value)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { wireguardSpec, WireguardSpec } from './constants'
|
||||
Add Proxy
|
||||
</button>
|
||||
</h3>
|
||||
<table class="g-table" [proxies]="(proxies$ | async) || []"></table>
|
||||
<table class="g-table" [proxies]="proxies$ | async"></table>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
|
||||
@@ -2,15 +2,31 @@ import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { TuiDialogService, TuiLinkModule } from '@taiga-ui/core'
|
||||
import { TuiBadgeModule, TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { CB } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiDataListModule,
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiLinkModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiIconsModule } from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import {
|
||||
FormComponent,
|
||||
FormContext,
|
||||
} from 'src/app/routes/portal/components/form.component'
|
||||
import {
|
||||
DELETE_OPTIONS,
|
||||
ProxyUpdate,
|
||||
} from 'src/app/routes/portal/routes/system/settings/routes/proxies/constants'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { Proxy } from 'src/app/services/patch-db/data-model'
|
||||
import { ProxiesMenuComponent } from './menu.component'
|
||||
|
||||
@Component({
|
||||
selector: 'table[proxies]',
|
||||
@@ -18,49 +34,106 @@ import { ProxiesMenuComponent } from './menu.component'
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Type</th>
|
||||
<th>Used By</th>
|
||||
<th></th>
|
||||
<th [style.width.rem]="3.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let proxy of proxies">
|
||||
<td>{{ proxy.name }}</td>
|
||||
<td>{{ proxy.createdAt | date: 'short' }}</td>
|
||||
<td>{{ proxy.type }}</td>
|
||||
<td>
|
||||
<button
|
||||
*ngIf="getLength(proxy); else unused"
|
||||
tuiLink
|
||||
(click)="onUsedBy(proxy)"
|
||||
>
|
||||
Connections: {{ getLength(proxy) }}
|
||||
</button>
|
||||
<ng-template #unused>N/A</ng-template>
|
||||
</td>
|
||||
<td><proxies-menu [proxy]="proxy" /></td>
|
||||
</tr>
|
||||
@for (proxy of proxies; track $index) {
|
||||
<tr>
|
||||
<td class="title">{{ proxy.name }}</td>
|
||||
<td class="type">{{ proxy.type }}</td>
|
||||
<td class="used">
|
||||
@if (getLength(proxy); as length) {
|
||||
<button tuiLink (click)="onUsedBy(proxy)">
|
||||
Used by: {{ length }}
|
||||
</button>
|
||||
} @else {
|
||||
N/A
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
size="xs"
|
||||
iconLeft="tuiIconEdit2"
|
||||
(click)="rename(proxy)"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
size="xs"
|
||||
iconLeft="tuiIconTrash2"
|
||||
(click)="delete(proxy)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
@if (proxies) {
|
||||
<tr><td colspan="5">No proxies added</td></tr>
|
||||
} @else {
|
||||
<tr>
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
`,
|
||||
styles: `
|
||||
:host-context(tui-root._mobile) {
|
||||
tr {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
td:only-child {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.title {
|
||||
order: 1;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.actions {
|
||||
order: 2;
|
||||
padding: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.type {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.used {
|
||||
order: 4;
|
||||
text-align: right;
|
||||
|
||||
&:not(:has(button)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiButtonModule,
|
||||
TuiBadgeModule,
|
||||
TuiLinkModule,
|
||||
ProxiesMenuComponent,
|
||||
],
|
||||
imports: [CommonModule, TuiLinkModule, TuiIconsModule, TuiButtonModule],
|
||||
})
|
||||
export class ProxiesTableComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
|
||||
@Input()
|
||||
proxies: readonly Proxy[] = []
|
||||
|
||||
@Output()
|
||||
readonly delete = new EventEmitter<Proxy>()
|
||||
proxies: readonly Proxy[] | null = null
|
||||
|
||||
getLength({ usedBy }: Proxy) {
|
||||
return usedBy.domains.length + usedBy.services.length
|
||||
@@ -81,4 +154,54 @@ export class ProxiesTableComponent {
|
||||
|
||||
this.dialogs.open(message, { label: 'Used by', size: 's' }).subscribe()
|
||||
}
|
||||
|
||||
delete({ id }: Proxy) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, DELETE_OPTIONS)
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.deleteProxy({ id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async rename(proxy: Proxy) {
|
||||
const spec = { name: 'Name', required: { default: proxy.name } }
|
||||
const name = await CB.Value.text(spec).build({} as any)
|
||||
const options: Partial<TuiDialogOptions<FormContext<{ name: string }>>> = {
|
||||
label: `Rename ${proxy.name}`,
|
||||
data: {
|
||||
spec: { name },
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: value => this.update(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
this.formDialog.open(FormComponent, options)
|
||||
}
|
||||
|
||||
private async update(value: ProxyUpdate): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.updateProxy(value)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,17 @@ import {
|
||||
Input,
|
||||
OnChanges,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiLinkModule } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCheckboxModule,
|
||||
TuiFadeModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { TuiForModule } from '@taiga-ui/cdk'
|
||||
import { Session } from 'src/app/services/api/api.types'
|
||||
import { PlatformInfoPipe } from './platform-info.pipe'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@Component({
|
||||
selector: 'table[sessions]',
|
||||
@@ -23,15 +23,16 @@ import { FormsModule } from '@angular/forms'
|
||||
<thead>
|
||||
<tr>
|
||||
<th [style.width.%]="50" [style.padding-left.rem]="single ? null : 2">
|
||||
<input
|
||||
*ngIf="!single"
|
||||
tuiCheckbox
|
||||
size="s"
|
||||
type="checkbox"
|
||||
[disabled]="!sessions?.length"
|
||||
[ngModel]="all"
|
||||
(ngModelChange)="onAll($event)"
|
||||
/>
|
||||
@if (!single) {
|
||||
<input
|
||||
tuiCheckbox
|
||||
size="s"
|
||||
type="checkbox"
|
||||
[disabled]="!sessions?.length"
|
||||
[ngModel]="all"
|
||||
(ngModelChange)="onAll($event)"
|
||||
/>
|
||||
}
|
||||
User Agent
|
||||
</th>
|
||||
<th [style.width.%]="25">Platform</th>
|
||||
@@ -39,54 +40,94 @@ import { FormsModule } from '@angular/forms'
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let session of sessions; else: loading">
|
||||
<td [style.padding-left.rem]="single ? null : 2">
|
||||
<input
|
||||
*ngIf="!single"
|
||||
tuiCheckbox
|
||||
size="s"
|
||||
type="checkbox"
|
||||
[ngModel]="selected$.value.includes(session)"
|
||||
(ngModelChange)="onToggle(session)"
|
||||
/>
|
||||
{{ session.userAgent }}
|
||||
</td>
|
||||
<td *ngIf="session.metadata.platforms | platformInfo as info">
|
||||
<tui-icon [icon]="info.icon"></tui-icon>
|
||||
{{ info.name }}
|
||||
</td>
|
||||
<td>{{ session.lastActive }}</td>
|
||||
</tr>
|
||||
<ng-template #loading>
|
||||
<tr *ngFor="let _ of single ? [''] : ['', '']">
|
||||
<td colspan="5">
|
||||
<div class="tui-skeleton">Loading</div>
|
||||
@for (session of sessions; track $index) {
|
||||
<tr>
|
||||
<td [style.padding-left.rem]="single ? null : 2">
|
||||
@if (!single) {
|
||||
<input
|
||||
tuiCheckbox
|
||||
size="s"
|
||||
type="checkbox"
|
||||
[ngModel]="selected$.value.includes(session)"
|
||||
(ngModelChange)="onToggle(session)"
|
||||
/>
|
||||
}
|
||||
<span tuiFade class="agent">{{ session.userAgent }}</span>
|
||||
</td>
|
||||
@if (session.metadata.platforms | platformInfo; as info) {
|
||||
<td class="platform">
|
||||
<tui-icon [icon]="info.icon" />
|
||||
{{ info.name }}
|
||||
</td>
|
||||
}
|
||||
<td class="date">{{ session.lastActive | date: 'medium' }}</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
} @empty {
|
||||
@if (sessions) {
|
||||
<tr><td colspan="5">No sessions</td></tr>
|
||||
} @else {
|
||||
@for (item of single ? [''] : ['', '']; track $index) {
|
||||
<tr>
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.5rem;
|
||||
left: 0.25rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
input {
|
||||
@include fullsize();
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
padding: 0 0.25rem !important;
|
||||
}
|
||||
|
||||
.agent {
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.platform {
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiForModule,
|
||||
FormsModule,
|
||||
PlatformInfoPipe,
|
||||
TuiButtonModule,
|
||||
TuiLinkModule,
|
||||
PlatformInfoPipe,
|
||||
TuiIconModule,
|
||||
TuiCheckboxModule,
|
||||
FormsModule,
|
||||
TuiFadeModule,
|
||||
],
|
||||
})
|
||||
export class SSHTableComponent<T extends Session> implements OnChanges {
|
||||
|
||||
@@ -6,19 +6,14 @@ import {
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiLinkModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiFadeModule } from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { filter, take } from 'rxjs'
|
||||
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
|
||||
import { SSHKey } from 'src/app/services/api/api.types'
|
||||
import { filter, take } from 'rxjs'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { TuiForModule } from '@taiga-ui/cdk'
|
||||
|
||||
@Component({
|
||||
selector: 'table[keys]',
|
||||
@@ -33,34 +28,83 @@ import { TuiForModule } from '@taiga-ui/cdk'
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let key of keys; else: loading">
|
||||
<td>{{ key.hostname }}</td>
|
||||
<td>{{ key.createdAt | date: 'medium' }}</td>
|
||||
<td>{{ key.alg }}</td>
|
||||
<td>{{ key.fingerprint }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconTrash2"
|
||||
[style.display]="'flex'"
|
||||
(click)="delete(key)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<ng-template #loading>
|
||||
<tr *ngFor="let _ of ['', '']">
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
@for (key of keys; track $index) {
|
||||
<tr>
|
||||
<td class="title">{{ key.hostname }}</td>
|
||||
<td class="date">{{ key.createdAt | date: 'medium' }}</td>
|
||||
<td class="algorithm">{{ key.alg }}</td>
|
||||
<td class="fingerprint" tuiFade>{{ key.fingerprint }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconTrash2"
|
||||
(click)="delete(key)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
} @empty {
|
||||
@if (keys) {
|
||||
<tr><td colspan="5">No keys added</td></tr>
|
||||
} @else {
|
||||
@for (i of ['', '']; track $index) {
|
||||
<tr>
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
`,
|
||||
styles: `
|
||||
:host-context(tui-root._mobile) {
|
||||
tr {
|
||||
grid-template-columns: 3fr 2fr;
|
||||
}
|
||||
|
||||
td:only-child {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.title {
|
||||
order: 1;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.actions {
|
||||
order: 2;
|
||||
padding: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fingerprint {
|
||||
order: 3;
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.date {
|
||||
order: 4;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
|
||||
.algorithm {
|
||||
order: 5;
|
||||
text-align: right;
|
||||
|
||||
&::before {
|
||||
content: 'Algorithm: ';
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, TuiForModule, TuiButtonModule, TuiLinkModule],
|
||||
imports: [CommonModule, TuiButtonModule, TuiFadeModule],
|
||||
})
|
||||
export class SSHTableComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -21,10 +21,40 @@
|
||||
/>
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#ff5b71" />
|
||||
<style>
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
background: #222428;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
app-root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
<app-root>
|
||||
<img
|
||||
src="assets/img/icon.png"
|
||||
style="width: 8rem; height: 8rem"
|
||||
alt="Start OS"
|
||||
/>
|
||||
<h1>Loading</h1>
|
||||
<progress></progress>
|
||||
</app-root>
|
||||
<noscript>
|
||||
Please enable JavaScript to continue using this application.
|
||||
</noscript>
|
||||
|
||||
@@ -42,6 +42,7 @@ hr {
|
||||
tui-root._mobile & {
|
||||
// For tui-tab-bar
|
||||
height: calc(100vh - 3.875rem - var(--tui-height-l));
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +78,7 @@ hr {
|
||||
height: 2rem;
|
||||
padding: 0 0.25rem;
|
||||
box-shadow: inset 0 -1px var(--tui-clear);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
th {
|
||||
@@ -89,6 +91,46 @@ hr {
|
||||
}
|
||||
}
|
||||
|
||||
tui-root._mobile .g-table {
|
||||
min-width: 0;
|
||||
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
tr {
|
||||
position: relative;
|
||||
display: grid;
|
||||
border-radius: var(--tui-radius-l);
|
||||
padding: 0.375rem 0.5rem;
|
||||
// TODO: Theme
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
tr:has(:checked) {
|
||||
box-shadow: inset 0 0 0 0.125rem var(--tui-primary);
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
position: static;
|
||||
height: auto;
|
||||
min-height: 1.5rem;
|
||||
align-content: center;
|
||||
box-shadow: none;
|
||||
|
||||
&:not([tuiFade]) {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.g-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -127,6 +169,8 @@ hr {
|
||||
|
||||
a.g-action,
|
||||
button.g-action {
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
|
||||
Reference in New Issue
Block a user