Merge pull request #2632 from Start9Labs/tables

feat: add mobile view for all the tables
This commit is contained in:
Matt Hill
2024-07-10 11:49:24 -06:00
committed by GitHub
25 changed files with 1092 additions and 644 deletions

View File

@@ -16,7 +16,7 @@
.header-title span { .header-title span {
position: relative; position: relative;
} }
.header-title span:before { .header-title span::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 100%; top: 100%;

View File

@@ -214,7 +214,7 @@ body {
user-select: text; user-select: text;
} }
.loading-dots:after { .loading-dots::after {
content: '...'; content: '...';
overflow: hidden; overflow: hidden;
display: inline-block; display: inline-block;
@@ -303,4 +303,4 @@ h5,
h6, h6,
hr { hr {
margin: 0; margin: 0;
} }

View File

@@ -140,7 +140,7 @@
background: transparent !important; background: transparent !important;
} }
tui-root._mobile &:after { tui-root._mobile &::after {
display: none; display: none;
} }
} }
@@ -149,7 +149,7 @@ tui-dialog {
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
} }
tui-opt-group[data-label^='⚠️']:before { tui-opt-group[data-label^='⚠️']::before {
color: var(--tui-warning-fill); color: var(--tui-warning-fill);
} }

View File

@@ -26,7 +26,7 @@
opacity: 0; opacity: 0;
} }
&:after { &::after {
@include transition(opacity); @include transition(opacity);
content: ''; content: '';

View File

@@ -52,7 +52,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
} }
`, `,
styles: ` styles: `
:host-context(tui-root._mobile) *:before { :host-context(tui-root._mobile) *::before {
font-size: 1.5rem !important; font-size: 1.5rem !important;
} }
`, `,

View File

@@ -1,44 +1,34 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiDialogService, TuiSvgModule } from '@taiga-ui/core' import { TuiDialogService } from '@taiga-ui/core'
import { TuiFadeModule } from '@taiga-ui/experimental' import { TuiIconModule } from '@taiga-ui/experimental'
import { BackupsCreateService } from './services/create.service'
import { BackupsRestoreService } from './services/restore.service'
import { BackupsUpcomingComponent } from './components/upcoming.component' import { BackupsUpcomingComponent } from './components/upcoming.component'
import { TARGETS } from './modals/targets.component'
import { HISTORY } from './modals/history.component' import { HISTORY } from './modals/history.component'
import { JOBS } from './modals/jobs.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({ @Component({
template: ` template: `
<section> <section>
<h3 class="g-title">Options</h3> <h3 class="g-title">Options</h3>
<button @for (option of options; track $index) {
*ngFor="let option of options" <button class="g-action" (click)="option.action()">
class="g-action" <tui-icon [icon]="option.icon" />
(click)="option.action()" <div>
> <strong>{{ option.name }}</strong>
<tui-svg [src]="option.icon"></tui-svg> <div>{{ option.description }}</div>
<div> </div>
<strong>{{ option.name }}</strong> </button>
<div>{{ option.description }}</div> }
</div>
</button>
</section> </section>
<h3 class="g-title">Upcoming Jobs</h3> <h3 class="g-title">Upcoming Jobs</h3>
<div tuiFade class="g-hidden-scrollbar"> <table backupsUpcoming class="g-table"></table>
<table backupsUpcoming class="g-table"></table>
</div>
`, `,
host: { class: 'g-page' }, host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ imports: [BackupsUpcomingComponent, TuiIconModule],
CommonModule,
TuiSvgModule,
TuiFadeModule,
BackupsUpcomingComponent,
],
}) })
export default class BackupsComponent { export default class BackupsComponent {
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)

View File

@@ -25,51 +25,94 @@ import { UnknownDisk } from 'src/app/services/api/api.types'
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let disk of backupsPhysical; else: loading; empty: blank"> @for (disk of backupsPhysical; track $index) {
<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>
<tr> <tr>
<td colspan="5"> <td class="model">
<div class="tui-skeleton">Loading</div> {{ 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> </td>
</tr> </tr>
</ng-template> } @empty {
<ng-template #blank> @if (backupsPhysical) {
<tr> <tr>
<td colspan="5"> <td colspan="5">
To add a new physical backup target, connect the drive and click To add a new physical backup target, connect the drive and click
refresh. refresh.
</td> </td>
</tr> </tr>
</ng-template> } @else {
<tr>
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
</tr>
}
}
</tbody> </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, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ imports: [TuiButtonModule, UnitConversionPipesModule],
CommonModule,
TuiForModule,
TuiSvgModule,
TuiButtonModule,
UnitConversionPipesModule,
],
}) })
export class BackupsPhysicalComponent { export class BackupsPhysicalComponent {
@Input() @Input()

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -7,13 +6,8 @@ import {
Input, Input,
Output, Output,
} from '@angular/core' } from '@angular/core'
import { TuiForModule } from '@taiga-ui/cdk' import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental'
import {
TuiDialogOptions,
TuiDialogService,
TuiSvgModule,
} from '@taiga-ui/core'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit' import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { filter, map, Subject, switchMap } from 'rxjs' import { filter, map, Subject, switchMap } from 'rxjs'
import { BackupTarget } from 'src/app/services/api/api.types' import { BackupTarget } from 'src/app/services/api/api.types'
@@ -32,69 +26,114 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let target of backupsTargets; else: loading; empty: blank"> @for (target of backupsTargets; track $index) {
<td>{{ target.name }}</td> <tr>
<td> <td class="title">{{ target.name }}</td>
<tui-svg [src]="target.type | getBackupIcon"></tui-svg> <td class="type">
{{ target.type | titlecase }} <tui-icon [icon]="target.type | getBackupIcon" />
</td> {{ target.type }}
<td> </td>
<tui-svg <td class="available">
[src]="target.mountable ? 'tuiIconCheck' : 'tuiIconClose'" <tui-icon
[style.color]=" [icon]="target.mountable ? 'tuiIconCheck' : 'tuiIconClose'"
target.mountable ? 'var(--tui-positive)' : 'var(--tui-negative)' [class]="target.mountable ? 'g-success' : 'g-error'"
" />
></tui-svg> </td>
</td> <td class="path">{{ target.path }}</td>
<td>{{ target.path }}</td> <td class="actions">
<td> <button
<button tuiIconButton
tuiIconButton size="xs"
size="xs" appearance="icon"
appearance="icon" iconLeft="tuiIconEdit2"
iconLeft="tuiIconEdit2" (click)="update.emit(target)"
(click)="update.emit(target)" >
> Update
Update </button>
</button> <button
<button tuiIconButton
tuiIconButton size="xs"
size="xs" appearance="icon"
appearance="icon" iconLeft="tuiIconTrash2"
iconLeft="tuiIconTrash2" (click)="delete$.next(target.id)"
(click)="delete$.next(target.id)" >
> Delete
Delete </button>
</button>
</td>
</tr>
<ng-template #loading>
<tr *ngFor="let i of ['', '']">
<td colspan="5">
<div class="tui-skeleton">Loading</div>
</td> </td>
</tr> </tr>
</ng-template> } @empty {
<ng-template #blank> @if (backupsTargets) {
<tr><td colspan="5">No saved backup targets.</td></tr> <tr><td colspan="5">No saved backup targets.</td></tr>
</ng-template> } @else {
@for (i of ['', '']; track $index) {
<tr>
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
</tr>
}
}
}
</tbody> </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, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ imports: [TuiButtonModule, GetBackupIconPipe, TuiIconModule],
CommonModule,
TuiForModule,
TuiSvgModule,
TuiButtonModule,
GetBackupIconPipe,
],
}) })
export class BackupsTargetsComponent { export class BackupsTargetsComponent {
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)
readonly delete$ = new Subject<string>() readonly delete$ = new Subject<string>()
readonly update$ = new Subject<BackupTarget>()
@Input() @Input()
backupsTargets: readonly BackupTarget[] | null = null backupsTargets: readonly BackupTarget[] | null = null

View File

@@ -1,12 +1,12 @@
import { CommonModule } from '@angular/common' import { DatePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiForModule } from '@taiga-ui/cdk' import { toSignal } from '@angular/core/rxjs-interop'
import { TuiSvgModule } from '@taiga-ui/core' import { TuiIconModule } from '@taiga-ui/experimental'
import { CronJob } from 'cron'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { from, map } from 'rxjs' 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 { 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' import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
@Component({ @Component({
@@ -20,58 +20,101 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
<th>Packages</th> <th>Packages</th>
</tr> </tr>
</thead> </thead>
<tbody *ngIf="current$ | async as current"> @if (current(); as current) {
<tr *ngFor="let job of upcoming$ | async; else: loading; empty: blank"> <tbody>
<td> @for (job of upcoming(); track $index) {
<span <tr>
*ngIf="current.id === job.id; else notRunning" <td class="date">
[style.color]="'var(--tui-positive)'" @if (current.id === job.id) {
> <span [style.color]="'var(--tui-positive)'">Running</span>
Running } @else {
</span> {{ job.next | date: 'medium' }}
<ng-template #notRunning> }
{{ job.next | date: 'MMM d, y, h:mm a' }} </td>
</ng-template> <td class="name">{{ job.name }}</td>
</td> <td>
<td>{{ job.name }}</td> <tui-icon [icon]="job.target.type | getBackupIcon" />
<td> {{ job.target.name }}
<tui-svg [src]="job.target.type | getBackupIcon"></tui-svg> </td>
{{ job.target.name }} <td class="packages">Packages: {{ job.packageIds.length }}</td>
</td> </tr>
<td>Packages: {{ job.packageIds.length }}</td> } @empty {
</tr> @if (upcoming()) {
<ng-template #blank> <tr>
<tr><td colspan="5">You have no active or upcoming backup jobs</td></tr> <td colspan="5">You have no active or upcoming backup jobs</td>
</ng-template> </tr>
<ng-template #loading> } @else {
<tr *ngFor="let row of ['', '']"> @for (row of ['', '']; track $index) {
<td colspan="5"><div class="tui-skeleton">Loading</div></td> <tr>
</tr> <td colspan="5"><div class="tui-skeleton">Loading</div></td>
</ng-template> </tr>
</tbody> }
}
}
</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, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, TuiForModule, TuiSvgModule, GetBackupIconPipe], imports: [GetBackupIconPipe, DatePipe, TuiIconModule],
}) })
export class BackupsUpcomingComponent { export class BackupsUpcomingComponent {
readonly current$ = inject(PatchDB<DataModel>) readonly current = toSignal(
.watch$('serverInfo', 'statusInfo', 'currentBackup', 'job') inject(PatchDB<DataModel>)
.pipe(map(job => job || {})) .watch$('serverInfo', 'statusInfo', 'currentBackup', 'job')
.pipe(map(job => job || {})),
)
readonly upcoming$ = from(inject(ApiService).getBackupJobs({})).pipe( readonly upcoming = toSignal(
map(jobs => from(inject(ApiService).getBackupJobs({})).pipe(
jobs map(jobs =>
.map(job => { jobs
const nextDate = new CronJob(job.cron, () => {}).nextDate() .map(job => {
const nextDate = new CronJob(job.cron, () => {}).nextDate()
return { return {
...job, ...job,
next: nextDate.toISO(), next: nextDate.toISO(),
diff: nextDate.diffNow().milliseconds, diff: nextDate.diffNow().milliseconds,
} }
}) })
.sort((a, b) => a.diff - b.diff), .sort((a, b) => a.diff - b.diff),
),
), ),
) )
} }

View File

@@ -1,27 +1,29 @@
import { CommonModule } from '@angular/common' 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 { FormsModule } from '@angular/forms'
import { ErrorService, LoadingService } from '@start9labs/shared' 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 { import {
ALWAYS_FALSE_HANDLER, TuiButtonModule,
ALWAYS_TRUE_HANDLER, TuiCheckboxModule,
TuiForModule, TuiIconModule,
} from '@taiga-ui/cdk' } from '@taiga-ui/experimental'
import { TuiDialogService, TuiLinkModule, TuiSvgModule } from '@taiga-ui/core' import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { TuiButtonModule, TuiFadeModule } from '@taiga-ui/experimental' import { REPORT } from 'src/app/components/report.component'
import { TuiCheckboxModule } from '@taiga-ui/kit'
import { BehaviorSubject } from 'rxjs'
import { BackupRun } from 'src/app/services/api/api.types' import { BackupRun } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' 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 { DurationPipe } from '../pipes/duration.pipe'
import { HasErrorPipe } from '../pipes/has-error.pipe'
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe' import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
import { HasErrorPipe } from '../pipes/has-error.pipe'
@Component({ @Component({
template: ` template: `
<ng-container *ngIf="loading$ | async"></ng-container>
<h3 class="g-title"> <h3 class="g-title">
Past Events Past Events
<button <button
@@ -33,83 +35,133 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
Delete Selected Delete Selected
</button> </button>
</h3> </h3>
<div tuiFade class="g-hidden-scrollbar"> <table class="g-table">
<table class="g-table"> <thead>
<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> <tr>
<th> <td class="checkbox">
<tui-checkbox <input
[disabled]="!selected.length" type="checkbox"
[ngModel]="all" tuiCheckbox
(ngModelChange)="toggle()" size="s"
></tui-checkbox> [(ngModel)]="selected[$index]"
</th> />
<th>Started At</th> </td>
<th>Duration</th> <td class="date">{{ run.startedAt | date: 'medium' }}</td>
<th>Result</th> <td class="duration">
<th>Job</th> {{ run.startedAt | duration: run.completedAt }} minutes
<th>Target</th> </td>
</tr> <td class="title">{{ run.job.name || 'No job' }}</td>
</thead> <td class="result">
<tbody> @if (run.report | hasError) {
<tr <tui-icon icon="tuiIconClose" class="g-error" />
*ngFor=" } @else {
let run of runs; <tui-icon icon="tuiIconCheck" class="g-success" />
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>
<button tuiLink (click)="showReport(run)">Report</button> <button tuiLink (click)="showReport(run)">Report</button>
</td> </td>
<td>{{ run.job.name || 'No job' }}</td> <td [style.grid-column]="'span 2'">
<td> <tui-icon [icon]="run.job.target.type | getBackupIcon" />
<tui-svg [src]="run.job.target.type | getBackupIcon"></tui-svg>
{{ run.job.target.name }} {{ run.job.target.name }}
</td> </td>
</tr> </tr>
<ng-template #loading> } @empty {
<tr *ngFor="let row of ['', '', '']"> @if (runs()) {
<td colspan="6"><div class="tui-skeleton">Loading</div></td>
</tr>
</ng-template>
<ng-template #blank>
<tr><td colspan="6">No backups have been run yet.</td></tr> <tr><td colspan="6">No backups have been run yet.</td></tr>
</ng-template> } @else {
</tbody> @for (row of ['', '']; track $index) {
</table> <tr>
</div> <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, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
TuiForModule,
TuiButtonModule, TuiButtonModule,
TuiCheckboxModule, TuiIconModule,
TuiSvgModule,
TuiLinkModule, TuiLinkModule,
TuiFadeModule,
DurationPipe, DurationPipe,
HasErrorPipe, HasErrorPipe,
GetBackupIconPipe, GetBackupIconPipe,
TuiCheckboxModule,
], ],
}) })
export class BackupsHistoryModal { export class BackupsHistoryModal {
@@ -118,9 +170,7 @@ export class BackupsHistoryModal {
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
readonly loading$ = new BehaviorSubject(true) runs = signal<BackupRun[] | null>(null)
runs: BackupRun[] | null = null
selected: boolean[] = [] selected: boolean[] = []
get all(): boolean | null { get all(): boolean | null {
@@ -143,13 +193,11 @@ export class BackupsHistoryModal {
async ngOnInit() { async ngOnInit() {
try { try {
this.runs = await this.api.getBackupRuns({}) this.runs.set(await this.api.getBackupRuns({}))
this.selected = this.runs.map(ALWAYS_FALSE_HANDLER) this.selected = this.runs()?.map(ALWAYS_FALSE_HANDLER) || []
} catch (e: any) { } catch (e: any) {
this.runs = [] this.runs.set([])
this.errorService.handleError(e) this.errorService.handleError(e)
} finally {
this.loading$.next(false)
} }
} }
@@ -157,12 +205,12 @@ export class BackupsHistoryModal {
const loader = this.loader.open('Deleting...').subscribe() const loader = this.loader.open('Deleting...').subscribe()
const ids = this.selected const ids = this.selected
.filter(Boolean) .filter(Boolean)
.map((_, i) => this.runs?.[i].id || '') .map((_, i) => this.runs()?.[i].id || '')
try { try {
await this.api.deleteBackupRuns({ ids }) await this.api.deleteBackupRuns({ ids })
this.runs = this.runs?.filter(r => !ids.includes(r.id)) || [] this.runs.set(this.runs()?.filter(r => !ids.includes(r.id)) || [])
this.selected = this.runs.map(ALWAYS_FALSE_HANDLER) this.selected = this.runs()?.map(ALWAYS_FALSE_HANDLER) || []
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
} finally { } finally {

View File

@@ -1,22 +1,19 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core' import { Component, inject, OnInit } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiForModule } from '@taiga-ui/cdk'
import { import {
TuiDialogOptions, TuiDialogOptions,
TuiDialogService, TuiDialogService,
TuiNotificationModule, TuiNotificationModule,
TuiSvgModule,
} from '@taiga-ui/core' } 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 { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus' import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { BehaviorSubject, filter } from 'rxjs' import { BehaviorSubject, filter } from 'rxjs'
import { BackupJob } from 'src/app/services/api/api.types' import { BackupJob } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' 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 { 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' import { EDIT } from './edit.component'
@Component({ @Component({
@@ -39,27 +36,27 @@ import { EDIT } from './edit.component'
Create New Job Create New Job
</button> </button>
</h3> </h3>
<div class="g-hidden-scrollbar" tuiFade> <table class="g-table">
<table class="g-table"> <thead>
<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> <tr>
<th>Name</th> <td class="title">{{ job.name }}</td>
<th>Target</th> <td class="target">
<th>Packages</th> <tui-icon [icon]="job.target.type | getBackupIcon" />
<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>
{{ job.target.name }} {{ job.target.name }}
</td> </td>
<td>Packages: {{ job.packageIds.length }}</td> <td class="packages">Packages: {{ job.packageIds.length }}</td>
<td>{{ (job.cron | toHumanCron).message }}</td> <td class="schedule">{{ (job.cron | toHumanCron).message }}</td>
<td> <td class="actions">
<button <button
tuiIconButton tuiIconButton
appearance="icon" appearance="icon"
@@ -76,26 +73,68 @@ import { EDIT } from './edit.component'
></button> ></button>
</td> </td>
</tr> </tr>
<ng-template #loading> } @empty {
<tr *ngFor="let i of ['', '']"> @if (jobs) {
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
</tr>
</ng-template>
<ng-template #blank>
<tr><td colspan="5">No jobs found.</td></tr> <tr><td colspan="5">No jobs found.</td></tr>
</ng-template> } @else {
</tbody> @for (i of ['', '']; track $index) {
</table> <tr>
</div> <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, standalone: true,
imports: [ imports: [
CommonModule,
TuiForModule,
TuiNotificationModule, TuiNotificationModule,
TuiButtonModule, TuiButtonModule,
TuiSvgModule, TuiIconModule,
TuiFadeModule,
ToHumanCronPipe, ToHumanCronPipe,
GetBackupIconPipe, GetBackupIconPipe,
], ],

View File

@@ -1,21 +1,11 @@
import { CommonModule } from '@angular/common' 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 { ErrorService, LoadingService } from '@start9labs/shared'
import { CT } from '@start9labs/start-sdk' import { CT } from '@start9labs/start-sdk'
import { TuiNotificationModule } from '@taiga-ui/core' 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 { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { BehaviorSubject } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component' 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 { import {
BackupTarget, BackupTarget,
BackupTargetType, BackupTargetType,
@@ -23,13 +13,21 @@ import {
UnknownDisk, UnknownDisk,
} from 'src/app/services/api/api.types' } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' 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 { BackupsPhysicalComponent } from '../components/physical.component'
import { BackupsTargetsComponent } from '../components/targets.component' import { BackupsTargetsComponent } from '../components/targets.component'
import { BackupConfig } from '../types/backup-config'
import {
cifsSpec,
diskBackupTargetSpec,
dropboxSpec,
googleDriveSpec,
remoteBackupTargetSpec,
} from '../types/target'
@Component({ @Component({
template: ` template: `
<ng-container *ngIf="loading$ | async"></ng-container>
<tui-notification> <tui-notification>
Backup targets are physical or virtual locations for storing encrypted Backup targets are physical or virtual locations for storing encrypted
backups. They can be physical drives plugged into your server, shared backups. They can be physical drives plugged into your server, shared
@@ -45,31 +43,32 @@ import { BackupsTargetsComponent } from '../components/targets.component'
</tui-notification> </tui-notification>
<h3 class="g-title"> <h3 class="g-title">
Unknown Physical Drives Unknown Physical Drives
<button tuiButton size="s" icon="tuiIconRefreshCw" (click)="refresh()"> <button
tuiButton
size="s"
iconLeft="tuiIconRefreshCw"
(click)="refresh()"
>
Refresh Refresh
</button> </button>
</h3> </h3>
<div class="g-hidden-scrollbar" tuiFade> <table
<table class="g-table"
class="g-table" [backupsPhysical]="targets()?.unknownDisks || null"
[backupsPhysical]="targets?.unknownDisks || null" (add)="addPhysical($event)"
(add)="addPhysical($event)" ></table>
></table>
</div>
<h3 class="g-title"> <h3 class="g-title">
Saved Targets Saved Targets
<button tuiButton size="s" icon="tuiIconPlus" (click)="addRemote()"> <button tuiButton size="s" iconLeft="tuiIconPlus" (click)="addRemote()">
Add Target Add Target
</button> </button>
</h3> </h3>
<div class="g-hidden-scrollbar" tuiFade> <table
<table class="g-table"
class="g-table" [backupsTargets]="targets()?.saved || null"
[backupsTargets]="targets?.saved || null" (delete)="onDelete($event)"
(delete)="onDelete($event)" (update)="onUpdate($event)"
(update)="onUpdate($event)" ></table>
></table>
</div>
`, `,
standalone: true, standalone: true,
imports: [ imports: [
@@ -78,7 +77,6 @@ import { BackupsTargetsComponent } from '../components/targets.component'
TuiButtonModule, TuiButtonModule,
BackupsPhysicalComponent, BackupsPhysicalComponent,
BackupsTargetsComponent, BackupsTargetsComponent,
TuiFadeModule,
], ],
}) })
export class BackupsTargetsModal implements OnInit { export class BackupsTargetsModal implements OnInit {
@@ -87,25 +85,20 @@ export class BackupsTargetsModal implements OnInit {
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
readonly loading$ = new BehaviorSubject(true) targets = signal<RR.GetBackupTargetsRes | null>(null)
targets?: RR.GetBackupTargetsRes
ngOnInit() { ngOnInit() {
this.refresh() this.refresh()
} }
async refresh() { async refresh() {
this.loading$.next(true) this.targets.set(null)
this.targets = undefined
try { try {
this.targets = await this.api.getBackupTargets({}) this.targets.set(await this.api.getBackupTargets({}))
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
this.targets = { unknownDisks: [], saved: [] } this.targets.set({ unknownDisks: [], saved: [] })
} finally {
this.loading$.next(false)
} }
} }
@@ -114,7 +107,7 @@ export class BackupsTargetsModal implements OnInit {
try { try {
await this.api.removeBackupTarget({ id }) 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) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
} finally { } finally {
@@ -158,8 +151,8 @@ export class BackupsTargetsModal implements OnInit {
...value, ...value,
}).then(response => { }).then(response => {
this.setTargets( this.setTargets(
this.targets?.saved.concat(response), this.targets()?.saved.concat(response),
this.targets?.unknownDisks.filter(a => a !== disk), this.targets()?.unknownDisks.filter(a => a !== disk),
) )
return true return true
}), }),
@@ -221,10 +214,10 @@ export class BackupsTargetsModal implements OnInit {
} }
private setTargets( private setTargets(
saved: BackupTarget[] = this.targets?.saved || [], saved: BackupTarget[] = this.targets()?.saved || [],
unknownDisks: UnknownDisk[] = this.targets?.unknownDisks || [], unknownDisks: UnknownDisk[] = this.targets()?.unknownDisks || [],
) { ) {
this.targets = { unknownDisks, saved } this.targets.set({ unknownDisks, saved })
} }
private async getSpec(target: BackupTarget) { private async getSpec(target: BackupTarget) {

View File

@@ -45,7 +45,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
top: 50%; top: 50%;
left: 50%; left: 50%;
&:before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
inset: -0.5rem; inset: -0.5rem;
@@ -53,7 +53,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
border-radius: 100%; border-radius: 100%;
} }
&:after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
width: 0.25rem; width: 0.25rem;

View File

@@ -131,7 +131,7 @@ import { TimeService } from 'src/app/services/time.service'
color: var(--tui-text-01); color: var(--tui-text-01);
padding-top: 0.4rem; padding-top: 0.4rem;
&:after { &::after {
content: attr(data-unit); content: attr(data-unit);
font-size: 0.5rem; font-size: 0.5rem;
font-weight: normal; font-weight: normal;

View File

@@ -8,7 +8,8 @@ import {
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { tuiPure } from '@taiga-ui/cdk' 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 { TuiLineClampModule } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { first, Observable } from 'rxjs' import { first, Observable } from 'rxjs'
@@ -20,22 +21,24 @@ import { toRouterLink } from 'src/app/utils/to-router-link'
@Component({ @Component({
selector: '[notificationItem]', selector: '[notificationItem]',
template: ` template: `
<td [style.padding-top.rem]="0.4"><ng-content /></td> <td class="checkbox"><ng-content /></td>
<td>{{ notificationItem.createdAt | date: 'MMM d, y, h:mm a' }}</td> <td class="date">
<td [style.color]="color"> {{ notificationItem.createdAt | date: 'medium' }}
<tui-svg [src]="icon" /> </td>
<td class="title" [style.color]="color">
<tui-icon [icon]="icon" [style.font-size.rem]="1" />
{{ notificationItem.title }} {{ notificationItem.title }}
</td> </td>
<td> <td class="service">
<a @if (manifest$ | async; as manifest) {
*ngIf="manifest$ | async as manifest; else na" <a tuiLink [routerLink]="getLink(manifest.id)">
[routerLink]="getLink(manifest.id)" {{ manifest.title }}
> </a>
{{ manifest.title }} } @else {
</a> N/A
<ng-template #na>N/A</ng-template> }
</td> </td>
<td [style.padding-bottom.rem]="0.5"> <td class="content">
<tui-line-clamp <tui-line-clamp
style="pointer-events: none" style="pointer-events: none"
[linesLimit]="4" [linesLimit]="4"
@@ -43,20 +46,16 @@ import { toRouterLink } from 'src/app/utils/to-router-link'
[content]="notificationItem.message" [content]="notificationItem.message"
(overflownChange)="overflow = $event" (overflownChange)="overflow = $event"
/> />
<button @if (overflow) {
*ngIf="overflow" <button tuiLink (click)="service.viewFull(notificationItem)">
tuiLink View Full
(click)="service.viewFull(notificationItem)" </button>
> }
View Full @if (notificationItem.code === 1) {
</button> <button tuiLink (click)="service.viewReport(notificationItem)">
<button View Report
*ngIf="notificationItem.code === 1" </button>
tuiLink }
(click)="service.viewReport(notificationItem)"
>
View Report
</button>
</td> </td>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -65,21 +64,58 @@ import { toRouterLink } from 'src/app/utils/to-router-link'
'[class._new]': '!notificationItem.read', '[class._new]': '!notificationItem.read',
}, },
styles: ` styles: `
:host._new { @import '@taiga-ui/core/styles/taiga-ui-local';
background: var(--tui-clear);
:host {
grid-template-columns: 1fr;
&._new {
background: var(--tui-clear) !important;
}
}
button {
position: relative;
} }
td { td {
padding: 0.25rem; padding: 0.25rem;
vertical-align: top; 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: [ imports: [
CommonModule, CommonModule,
RouterLink, RouterLink,
TuiLineClampModule, TuiLineClampModule,
TuiSvgModule,
TuiLinkModule, TuiLinkModule,
TuiIconModule,
], ],
}) })
export class NotificationItemComponent { export class NotificationItemComponent {

View File

@@ -66,6 +66,14 @@ import { NotificationItemComponent } from './item.component'
} }
</tbody> </tbody>
`, `,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
:host-context(tui-root._mobile) input {
@include fullsize();
opacity: 0;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ imports: [

View File

@@ -17,7 +17,6 @@ import { Domain } from 'src/app/services/patch-db/data-model'
<thead> <thead>
<tr> <tr>
<th>Domain</th> <th>Domain</th>
<th>Added</th>
<th>DDNS Provider</th> <th>DDNS Provider</th>
<th>Network Strategy</th> <th>Network Strategy</th>
<th>Used By</th> <th>Used By</th>
@@ -25,36 +24,87 @@ import { Domain } from 'src/app/services/patch-db/data-model'
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let domain of domains"> @for (domain of domains; track $index) {
<td>{{ domain.value }}</td> <tr *ngFor="let domain of domains">
<td>{{ domain.createdAt | date: 'short' }}</td> <td class="title">{{ domain.value }}</td>
<td>{{ domain.provider }}</td> <td class="provider">{{ domain.provider }}</td>
<td>{{ getStrategy(domain) }}</td> <td class="strategy">{{ getStrategy(domain) }}</td>
<td> <td class="used">
<button @if (domain.usedBy.length; as qty) {
*ngIf="domain.usedBy.length as qty; else unused" <button tuiLink (click)="onUsedBy(domain)">
tuiLink Used by: {{ qty }}
(click)="onUsedBy(domain)" </button>
> } @else {
Interfaces: {{ qty }} N/A
</button> }
<ng-template #unused>N/A</ng-template> </td>
</td> <td class="actions">
<td> <button
<button tuiIconButton
tuiIconButton size="xs"
size="xs" appearance="icon"
appearance="icon" iconLeft="tuiIconTrash2"
iconLeft="tuiIconTrash2" (click)="delete.emit(domain)"
[style.display]="'flex'" >
(click)="delete.emit(domain)" Delete
> </button>
Delete </td>
</button> </tr>
</td> } @empty {
</tr> <tr><td colspan="6">No domains</td></tr>
}
</tbody> </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, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiButtonModule, TuiLinkModule], imports: [CommonModule, TuiButtonModule, TuiLinkModule],

View File

@@ -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()
}
}
}

View File

@@ -24,7 +24,7 @@ import { wireguardSpec, WireguardSpec } from './constants'
Add Proxy Add Proxy
</button> </button>
</h3> </h3>
<table class="g-table" [proxies]="(proxies$ | async) || []"></table> <table class="g-table" [proxies]="proxies$ | async"></table>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,

View File

@@ -2,15 +2,31 @@ import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter,
inject, inject,
Input, Input,
Output,
} from '@angular/core' } from '@angular/core'
import { TuiDialogService, TuiLinkModule } from '@taiga-ui/core' import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiBadgeModule, TuiButtonModule } from '@taiga-ui/experimental' 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 { Proxy } from 'src/app/services/patch-db/data-model'
import { ProxiesMenuComponent } from './menu.component'
@Component({ @Component({
selector: 'table[proxies]', selector: 'table[proxies]',
@@ -18,49 +34,106 @@ import { ProxiesMenuComponent } from './menu.component'
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Created</th>
<th>Type</th> <th>Type</th>
<th>Used By</th> <th>Used By</th>
<th></th> <th [style.width.rem]="3.5"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let proxy of proxies"> @for (proxy of proxies; track $index) {
<td>{{ proxy.name }}</td> <tr>
<td>{{ proxy.createdAt | date: 'short' }}</td> <td class="title">{{ proxy.name }}</td>
<td>{{ proxy.type }}</td> <td class="type">{{ proxy.type }}</td>
<td> <td class="used">
<button @if (getLength(proxy); as length) {
*ngIf="getLength(proxy); else unused" <button tuiLink (click)="onUsedBy(proxy)">
tuiLink Used by: {{ length }}
(click)="onUsedBy(proxy)" </button>
> } @else {
Connections: {{ getLength(proxy) }} N/A
</button> }
<ng-template #unused>N/A</ng-template> </td>
</td> <td class="actions">
<td><proxies-menu [proxy]="proxy" /></td> <button
</tr> 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> </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, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [CommonModule, TuiLinkModule, TuiIconsModule, TuiButtonModule],
CommonModule,
TuiButtonModule,
TuiBadgeModule,
TuiLinkModule,
ProxiesMenuComponent,
],
}) })
export class ProxiesTableComponent { export class ProxiesTableComponent {
private readonly dialogs = inject(TuiDialogService) 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() @Input()
proxies: readonly Proxy[] = [] proxies: readonly Proxy[] | null = null
@Output()
readonly delete = new EventEmitter<Proxy>()
getLength({ usedBy }: Proxy) { getLength({ usedBy }: Proxy) {
return usedBy.domains.length + usedBy.services.length return usedBy.domains.length + usedBy.services.length
@@ -81,4 +154,54 @@ export class ProxiesTableComponent {
this.dialogs.open(message, { label: 'Used by', size: 's' }).subscribe() 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()
}
}
} }

View File

@@ -5,17 +5,17 @@ import {
Input, Input,
OnChanges, OnChanges,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiLinkModule } from '@taiga-ui/core' import { TuiLinkModule } from '@taiga-ui/core'
import { import {
TuiButtonModule, TuiButtonModule,
TuiCheckboxModule, TuiCheckboxModule,
TuiFadeModule,
TuiIconModule, TuiIconModule,
} from '@taiga-ui/experimental' } from '@taiga-ui/experimental'
import { BehaviorSubject } from 'rxjs' import { BehaviorSubject } from 'rxjs'
import { TuiForModule } from '@taiga-ui/cdk'
import { Session } from 'src/app/services/api/api.types' import { Session } from 'src/app/services/api/api.types'
import { PlatformInfoPipe } from './platform-info.pipe' import { PlatformInfoPipe } from './platform-info.pipe'
import { FormsModule } from '@angular/forms'
@Component({ @Component({
selector: 'table[sessions]', selector: 'table[sessions]',
@@ -23,15 +23,16 @@ import { FormsModule } from '@angular/forms'
<thead> <thead>
<tr> <tr>
<th [style.width.%]="50" [style.padding-left.rem]="single ? null : 2"> <th [style.width.%]="50" [style.padding-left.rem]="single ? null : 2">
<input @if (!single) {
*ngIf="!single" <input
tuiCheckbox tuiCheckbox
size="s" size="s"
type="checkbox" type="checkbox"
[disabled]="!sessions?.length" [disabled]="!sessions?.length"
[ngModel]="all" [ngModel]="all"
(ngModelChange)="onAll($event)" (ngModelChange)="onAll($event)"
/> />
}
User Agent User Agent
</th> </th>
<th [style.width.%]="25">Platform</th> <th [style.width.%]="25">Platform</th>
@@ -39,54 +40,94 @@ import { FormsModule } from '@angular/forms'
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let session of sessions; else: loading"> @for (session of sessions; track $index) {
<td [style.padding-left.rem]="single ? null : 2"> <tr>
<input <td [style.padding-left.rem]="single ? null : 2">
*ngIf="!single" @if (!single) {
tuiCheckbox <input
size="s" tuiCheckbox
type="checkbox" size="s"
[ngModel]="selected$.value.includes(session)" type="checkbox"
(ngModelChange)="onToggle(session)" [ngModel]="selected$.value.includes(session)"
/> (ngModelChange)="onToggle(session)"
{{ session.userAgent }} />
</td> }
<td *ngIf="session.metadata.platforms | platformInfo as info"> <span tuiFade class="agent">{{ session.userAgent }}</span>
<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>
</td> </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> </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> </tbody>
`, `,
styles: [ styles: [
` `
@import '@taiga-ui/core/styles/taiga-ui-local';
input { input {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 0.5rem; left: 0.25rem;
transform: translateY(-50%); 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, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule, CommonModule,
TuiForModule, FormsModule,
PlatformInfoPipe,
TuiButtonModule, TuiButtonModule,
TuiLinkModule, TuiLinkModule,
PlatformInfoPipe,
TuiIconModule, TuiIconModule,
TuiCheckboxModule, TuiCheckboxModule,
FormsModule, TuiFadeModule,
], ],
}) })
export class SSHTableComponent<T extends Session> implements OnChanges { export class SSHTableComponent<T extends Session> implements OnChanges {

View File

@@ -6,19 +6,14 @@ import {
inject, inject,
Input, Input,
} from '@angular/core' } from '@angular/core'
import { import { ErrorService, LoadingService } from '@start9labs/shared'
TuiDialogOptions, import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
TuiDialogService, import { TuiButtonModule, TuiFadeModule } from '@taiga-ui/experimental'
TuiLinkModule, import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
} from '@taiga-ui/core' import { filter, take } from 'rxjs'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component' import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { SSHKey } from 'src/app/services/api/api.types' 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 { ApiService } from 'src/app/services/api/embassy-api.service'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { TuiForModule } from '@taiga-ui/cdk'
@Component({ @Component({
selector: 'table[keys]', selector: 'table[keys]',
@@ -33,34 +28,83 @@ import { TuiForModule } from '@taiga-ui/cdk'
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let key of keys; else: loading"> @for (key of keys; track $index) {
<td>{{ key.hostname }}</td> <tr>
<td>{{ key.createdAt | date: 'medium' }}</td> <td class="title">{{ key.hostname }}</td>
<td>{{ key.alg }}</td> <td class="date">{{ key.createdAt | date: 'medium' }}</td>
<td>{{ key.fingerprint }}</td> <td class="algorithm">{{ key.alg }}</td>
<td> <td class="fingerprint" tuiFade>{{ key.fingerprint }}</td>
<button <td class="actions">
tuiIconButton <button
size="xs" tuiIconButton
appearance="icon" size="xs"
iconLeft="tuiIconTrash2" appearance="icon"
[style.display]="'flex'" iconLeft="tuiIconTrash2"
(click)="delete(key)" (click)="delete(key)"
> >
Delete Delete
</button> </button>
</td> </td>
</tr>
<ng-template #loading>
<tr *ngFor="let _ of ['', '']">
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
</tr> </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> </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, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiForModule, TuiButtonModule, TuiLinkModule], imports: [CommonModule, TuiButtonModule, TuiFadeModule],
}) })
export class SSHTableComponent { export class SSHTableComponent {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -21,10 +21,40 @@
/> />
<link rel="manifest" href="manifest.webmanifest" /> <link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#ff5b71" /> <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> </head>
<body> <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> <noscript>
Please enable JavaScript to continue using this application. Please enable JavaScript to continue using this application.
</noscript> </noscript>

View File

@@ -42,6 +42,7 @@ hr {
tui-root._mobile & { tui-root._mobile & {
// For tui-tab-bar // For tui-tab-bar
height: calc(100vh - 3.875rem - var(--tui-height-l)); height: calc(100vh - 3.875rem - var(--tui-height-l));
padding: 1rem;
} }
} }
@@ -77,6 +78,7 @@ hr {
height: 2rem; height: 2rem;
padding: 0 0.25rem; padding: 0 0.25rem;
box-shadow: inset 0 -1px var(--tui-clear); box-shadow: inset 0 -1px var(--tui-clear);
text-overflow: ellipsis;
} }
th { 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 { .g-title {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -127,6 +169,8 @@ hr {
a.g-action, a.g-action,
button.g-action { button.g-action {
cursor: pointer;
&:disabled { &:disabled {
pointer-events: none; pointer-events: none;
opacity: var(--tui-disabled-opacity); opacity: var(--tui-disabled-opacity);