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 {
position: relative;
}
.header-title span:before {
.header-title span::before {
content: "";
position: absolute;
top: 100%;

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -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;
}
`,

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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 {

View File

@@ -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,
],

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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: [

View File

@@ -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],

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
</button>
</h3>
<table class="g-table" [proxies]="(proxies$ | async) || []"></table>
<table class="g-table" [proxies]="proxies$ | async"></table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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>

View File

@@ -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);