refactor: completely remove ionic

This commit is contained in:
waterplea
2024-04-05 12:06:02 +07:00
parent b2c8907635
commit 8594781780
291 changed files with 416 additions and 3365 deletions

View File

@@ -0,0 +1,32 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { WorkspaceConfig } from '@start9labs/shared'
import { DiagnosticService } from './services/diagnostic.service'
import { MockDiagnosticService } from './services/mock-diagnostic.service'
import { LiveDiagnosticService } from './services/live-diagnostic.service'
const { useMocks } = require('../../../../../../config.json') as WorkspaceConfig
const ROUTES: Routes = [
{
path: '',
loadChildren: () =>
import('./home/home.module').then(m => m.HomePageModule),
},
{
path: 'logs',
loadChildren: () =>
import('./logs/logs.module').then(m => m.LogsPageModule),
},
]
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
providers: [
{
provide: DiagnosticService,
useClass: useMocks ? MockDiagnosticService : LiveDiagnosticService,
},
],
})
export class DiagnosticModule {}

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { RouterModule, Routes } from '@angular/router'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { HomePage } from './home.page'
const ROUTES: Routes = [
{
path: '',
component: HomePage,
},
]
@NgModule({
imports: [CommonModule, TuiButtonModule, RouterModule.forChild(ROUTES)],
declarations: [HomePage],
})
export class HomePageModule {}

View File

@@ -0,0 +1,53 @@
<ng-container *ngIf="!restarted; else refresh">
<h1 class="title">StartOS - Diagnostic Mode</h1>
<ng-container *ngIf="error">
<h2 class="subtitle">StartOS launch error:</h2>
<code class="code warning">
<p>{{ error.problem }}</p>
<p *ngIf="error.details">{{ error.details }}</p>
</code>
<a tuiButton routerLink="logs">View Logs</a>
<h2 class="subtitle">Possible solutions:</h2>
<code class="code"><p>{{ error.solution }}</p></code>
<div class="buttons">
<button tuiButton (click)="restart()">Restart Server</button>
<button
*ngIf="error.code === 15 || error.code === 25"
tuiButton
appearance="secondary"
(click)="forgetDrive()"
>
{{ error.code === 15 ? 'Setup Current Drive' : 'Enter Recovery Mode'}}
</button>
<button
tuiButton
appearance="secondary-warning"
(click)="presentAlertSystemRebuild()"
>
System Rebuild
</button>
<button
tuiButton
appearance="secondary-destructive"
(click)="presentAlertRepairDisk()"
>
Repair Drive
</button>
</div>
</ng-container>
</ng-container>
<ng-template #refresh>
<h1 class="title">Server is restarting</h1>
<h2 class="subtitle">
Wait for the server to restart, then refresh this page.
</h2>
<button tuiButton (click)="refreshPage()">Refresh</button>
</ng-template>

View File

@@ -0,0 +1,37 @@
:host {
display: block;
padding: 2rem;
overflow: auto;
background: var(--tui-base-01);
}
.title {
text-align: center;
padding-bottom: 1.5rem;
font-size: calc(2vw + 1rem);
}
.subtitle {
padding-top: 1rem;
padding-bottom: 1rem;
font-size: calc(1vw + 0.75rem);
font-weight: bold;
}
.code {
display: block;
color: var(--tui-success-fill);
background: rgb(69, 69, 69);
padding: 1px 1rem;
margin-bottom: 2rem;
}
.warning {
color: var(--tui-warning-fill);
}
.buttons {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}

View File

@@ -0,0 +1,194 @@
import { Component, Inject } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { DiagnosticService } from '../services/diagnostic.service'
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {
restarted = false
error?: {
code: number
problem: string
solution: string
details?: string
}
constructor(
private readonly loader: LoadingService,
private readonly api: DiagnosticService,
private readonly dialogs: TuiDialogService,
@Inject(WINDOW) private readonly window: Window,
) {}
async ngOnInit() {
try {
const error = await this.api.getError()
// incorrect drive
if (error.code === 15) {
this.error = {
code: 15,
problem: 'Unknown storage drive detected',
solution:
'To use a different storage drive, replace the current one and click RESTART SERVER below. To use the current storage drive, click USE CURRENT DRIVE below, then follow instructions. No data will be erased during this process.',
details: error.data?.details,
}
// no drive
} else if (error.code === 20) {
this.error = {
code: 20,
problem: 'Storage drive not found',
solution:
'Insert your StartOS storage drive and click RESTART SERVER below.',
details: error.data?.details,
}
// drive corrupted
} else if (error.code === 25) {
this.error = {
code: 25,
problem:
'Storage drive corrupted. This could be the result of data corruption or physical damage.',
solution:
'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.',
details: error.data?.details,
}
// filesystem I/O error - disk needs repair
} else if (error.code === 2) {
this.error = {
code: 2,
problem: 'Filesystem I/O error.',
solution:
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
details: error.data?.details,
}
// disk management error - disk needs repair
} else if (error.code === 48) {
this.error = {
code: 48,
problem: 'Disk management error.',
solution:
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
details: error.data?.details,
}
} else {
this.error = {
code: error.code,
problem: error.message,
solution: 'Please contact support.',
details: error.data?.details,
}
}
} catch (e) {
console.error(e)
}
}
async restart(): Promise<void> {
const loader = this.loader.open('').subscribe()
try {
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
async forgetDrive(): Promise<void> {
const loader = this.loader.open('').subscribe()
try {
await this.api.forgetDrive()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
async presentAlertSystemRebuild() {
this.dialogs
.open(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
no: 'Cancel',
yes: 'Rebuild',
content:
'<p>This action will tear down all service containers and rebuild them from scratch. No data will be deleted.</p><p>A system rebuild can be useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues.</p><p>It may take up to an hour to complete. During this time, you will lose all connectivity to your Start9 server.</p>',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
try {
this.systemRebuild()
} catch (e) {
console.error(e)
}
})
}
async presentAlertRepairDisk() {
this.dialogs
.open(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
no: 'Cancel',
yes: 'Repair',
content:
'<p>This action should only be executed if directed by a Start9 support specialist.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
try {
this.repairDisk()
} catch (e) {
console.error(e)
}
})
}
refreshPage(): void {
this.window.location.reload()
}
private async systemRebuild(): Promise<void> {
const loader = this.loader.open('').subscribe()
try {
await this.api.systemRebuild()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
private async repairDisk(): Promise<void> {
const loader = this.loader.open('').subscribe()
try {
await this.api.repairDisk()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,32 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IntersectionObserverModule } from '@ng-web-apis/intersection-observer'
import { MutationObserverModule } from '@ng-web-apis/mutation-observer'
import { TuiLoaderModule, TuiScrollbarModule } from '@taiga-ui/core'
import { TuiBadgeModule, TuiButtonModule } from '@taiga-ui/experimental'
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
import { LogsPage } from './logs.page'
const ROUTES: Routes = [
{
path: '',
component: LogsPage,
},
]
@NgModule({
imports: [
CommonModule,
RouterModule.forChild(ROUTES),
IntersectionObserverModule,
MutationObserverModule,
NgDompurifyModule,
TuiBadgeModule,
TuiButtonModule,
TuiLoaderModule,
TuiScrollbarModule,
],
declarations: [LogsPage],
})
export class LogsPageModule {}

View File

@@ -0,0 +1,23 @@
<a
routerLink="../"
tuiButton
iconLeft="tuiIconChevronLeft"
appearance="icon"
[style.align-self]="'flex-start'"
>
Back
</a>
<tui-scrollbar childList subtree (waMutationObserver)="restoreScroll()">
<section
class="top"
waIntersectionObserver
(waIntersectionObservee)="onTop($event[0].isIntersecting)"
>
@if (loading) {
<tui-loader textContent="Loading logs" />
}
</section>
@for (log of logs; track log) {
<pre [innerHTML]="log | dompurify"></pre>
}
</tui-scrollbar>

View File

@@ -0,0 +1,79 @@
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
import { INTERSECTION_ROOT } from '@ng-web-apis/intersection-observer'
import { convertAnsi, ErrorService } from '@start9labs/shared'
import { TuiScrollbarComponent } from '@taiga-ui/core'
import { DiagnosticService } from 'src/app/routes/diagnostic/services/diagnostic.service'
@Component({
selector: 'logs',
templateUrl: './logs.page.html',
styles: `
:host {
max-height: 100vh;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 1rem;
gap: 1rem;
background: var(--tui-base-01);
}
`,
providers: [
{
provide: INTERSECTION_ROOT,
useExisting: ElementRef,
},
],
})
export class LogsPage implements OnInit {
@ViewChild(TuiScrollbarComponent, { read: ElementRef })
private readonly scrollbar?: ElementRef<HTMLElement>
private readonly api = inject(DiagnosticService)
private readonly errorService = inject(ErrorService)
startCursor?: string
loading = false
logs: string[] = []
scrollTop = 0
ngOnInit() {
this.getLogs()
}
onTop(top: boolean) {
if (top) this.getLogs()
}
restoreScroll() {
if (this.loading || !this.scrollbar) return
const scrollbar = this.scrollbar.nativeElement
const offset = scrollbar.querySelector('pre')?.clientHeight || 0
scrollbar.scrollTop = this.scrollTop + offset
}
private async getLogs() {
if (this.loading) return
this.loading = true
try {
const response = await this.api.getLogs({
cursor: this.startCursor,
before: !!this.startCursor,
limit: 200,
})
if (!response.entries.length) return
this.startCursor = response.startCursor
this.logs = [convertAnsi(response.entries), ...this.logs]
this.scrollTop = this.scrollbar?.nativeElement.scrollTop || 0
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
}

View File

@@ -0,0 +1,16 @@
import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
export abstract class DiagnosticService {
abstract getError(): Promise<GetErrorRes>
abstract restart(): Promise<void>
abstract forgetDrive(): Promise<void>
abstract repairDisk(): Promise<void>
abstract systemRebuild(): Promise<void>
abstract getLogs(params: FetchLogsReq): Promise<FetchLogsRes>
}
export interface GetErrorRes {
code: number
message: string
data: { details: string }
}

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@angular/core'
import {
HttpService,
isRpcError,
RpcError,
RPCOptions,
} from '@start9labs/shared'
import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
import { DiagnosticService, GetErrorRes } from './diagnostic.service'
@Injectable()
export class LiveDiagnosticService implements DiagnosticService {
constructor(private readonly http: HttpService) {}
async getError(): Promise<GetErrorRes> {
return this.rpcRequest<GetErrorRes>({
method: 'diagnostic.error',
params: {},
})
}
async restart(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.restart',
params: {},
})
}
async forgetDrive(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.forget',
params: {},
})
}
async repairDisk(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.repair',
params: {},
})
}
async systemRebuild(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.rebuild',
params: {},
})
}
async getLogs(params: FetchLogsReq): Promise<FetchLogsRes> {
return this.rpcRequest<FetchLogsRes>({
method: 'diagnostic.logs',
params,
})
}
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
const res = await this.http.rpcRequest<T>(opts)
const rpcRes = res.body
if (isRpcError(rpcRes)) {
throw new RpcError(rpcRes.error)
}
return rpcRes.result
}
}

View File

@@ -0,0 +1,67 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import { FetchLogsReq, FetchLogsRes, Log } from '@start9labs/shared'
import { DiagnosticService, GetErrorRes } from './diagnostic.service'
@Injectable()
export class MockDiagnosticService implements DiagnosticService {
async getError(): Promise<GetErrorRes> {
await pauseFor(1000)
return {
code: 15,
message: 'Unknown server',
data: { details: 'Some details about the error here' },
}
}
async restart(): Promise<void> {
await pauseFor(1000)
}
async forgetDrive(): Promise<void> {
await pauseFor(1000)
}
async repairDisk(): Promise<void> {
await pauseFor(1000)
}
async systemRebuild(): Promise<void> {
await pauseFor(1000)
}
async getLogs(params: FetchLogsReq): Promise<FetchLogsRes> {
await pauseFor(1000)
let entries: Log[]
if (Math.random() < 0.2) {
entries = packageLogs
} else {
const arrLength = params.limit
? Math.ceil(params.limit / packageLogs.length)
: 10
entries = new Array(arrLength)
.fill(packageLogs)
.reduce((acc, val) => acc.concat(val), [])
}
return {
entries,
startCursor: 'start-cursor',
endCursor: 'end-cursor',
}
}
}
const packageLogs = [
{
timestamp: '2019-12-26T14:20:30.872Z',
message: '****** START *****',
},
{
timestamp: '2019-12-26T14:21:30.872Z',
message: 'ServerLogs ServerLogs ServerLogs ServerLogs ServerLogs',
},
{
timestamp: '2019-12-26T14:22:30.872Z',
message: '****** FINISH *****',
},
]

View File

@@ -0,0 +1,25 @@
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import {
InitializingComponent,
provideSetupLogsService,
provideSetupService,
} from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
standalone: true,
template: `
<app-initializing (finished)="router.navigate(['login'])" />
`,
providers: [
provideSetupService(ApiService),
provideSetupLogsService(ApiService),
],
styles: ':host { padding: 1rem; }',
imports: [InitializingComponent],
})
export default class LoadingPage {
readonly router = inject(Router)
}

View File

@@ -0,0 +1,106 @@
<div
*ngIf="!caTrusted; else trusted"
tuiCardLarge
tuiSurface="elevated"
class="card"
>
<tui-icon icon="tuiIconLock" [style.font-size.rem]="4" />
<h1>Trust Your Root CA</h1>
<p>
Download and trust your server's Root Certificate Authority to establish a
secure (HTTPS) connection. You will need to repeat this on every device you
use to connect to your server.
</p>
<ol>
<li>
<b>Bookmark this page</b>
- Save this page so you can access it later. You can also find the address
in the
<code>StartOS-info.html</code>
file downloaded at the end of initial setup.
</li>
<li>
<b>Download your server's Root CA</b>
- Your server uses its Root CA to generate SSL/TLS certificates for itself
and installed services. These certificates are then used to encrypt
network traffic with your client devices.
<br />
<a
tuiButton
size="s"
appearance="tertiary-solid"
iconRight="tuiIconDownload"
href="/eos/local.crt"
>
Download
</a>
</li>
<li>
<b>Trust your server's Root CA</b>
- Follow instructions for your OS. By trusting your server's Root CA, your
device can verify the authenticity of encrypted communications with your
server.
<br />
<a
tuiButton
size="s"
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca"
target="_blank"
rel="noreferrer"
iconRight="tuiIconExternalLink"
>
View Instructions
</a>
</li>
<li>
<b>Test</b>
- Refresh the page. If refreshing the page does not work, you may need to
quit and re-open your browser, then revisit this page.
<br />
<button
tuiButton
size="s"
class="refresh"
appearance="success-solid"
iconRight="tuiIconRefreshCw"
(click)="refresh()"
>
Refresh
</button>
</li>
</ol>
<button
tuiButton
size="s"
appearance="flat"
iconRight="tuiIconExternalLink"
(click)="launchHttps()"
[disabled]="caTrusted"
>
Skip
</button>
<div><small>(not recommended)</small></div>
</div>
<ng-template #trusted>
<div tuiCardLarge tuiSurface="elevated" class="card">
<tui-icon
icon="tuiIconShield"
tuiAppearance="icon-success"
[style.font-size.rem]="4"
/>
<h1>Root CA Trusted!</h1>
<p>
You have successfully trusted your server's Root CA and may now log in
securely.
</p>
<button
tuiButton
appearance="tertiary-solid"
iconRight="tuiIconExternalLink"
(click)="launchHttps()"
>
Go to login
</button>
</div>
</ng-template>

View File

@@ -0,0 +1,44 @@
:host {
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
}
[tuiButton] {
text-transform: uppercase;
font-weight: bold;
border-radius: 10rem;
margin-top: 1rem;
}
.card {
max-width: max(70%, 40rem);
text-align: center;
align-items: center !important;
gap: 0 !important;
}
h1 {
margin: 1rem;
font-weight: bold;
font-size: 1.5rem;
}
p {
font-size: 1.3rem;
line-height: 1.5rem;
margin: 0 0 2rem;
}
ol {
font-size: 1rem;
line-height: 1.5rem;
text-align: left;
}
li {
padding-bottom: 1.5rem;
}

View File

@@ -0,0 +1,56 @@
import { CommonModule, DOCUMENT } from '@angular/common'
import { Component, inject } from '@angular/core'
import { RELATIVE_URL } from '@start9labs/shared'
import {
TuiAppearanceModule,
TuiButtonModule,
TuiCardModule,
TuiIconModule,
TuiSurfaceModule,
} from '@taiga-ui/experimental'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
@Component({
standalone: true,
selector: 'ca-wizard',
templateUrl: './ca-wizard.component.html',
styleUrls: ['./ca-wizard.component.scss'],
imports: [
CommonModule,
TuiIconModule,
TuiButtonModule,
TuiAppearanceModule,
TuiCardModule,
TuiSurfaceModule,
],
})
export class CAWizardComponent {
private readonly api = inject(ApiService)
private readonly relativeUrl = inject(RELATIVE_URL)
private readonly document = inject(DOCUMENT)
readonly config = inject(ConfigService)
caTrusted = false
async ngOnInit() {
await this.testHttps().catch(e =>
console.warn('Failed Https connection attempt'),
)
}
refresh() {
this.document.location.reload()
}
launchHttps() {
this.document.defaultView?.open(`https://${this.config.getHost()}`, '_self')
}
private async testHttps() {
const url = `https://${this.document.location.host}${this.relativeUrl}`
await this.api.echo({ message: 'ping' }, url).then(() => {
this.caTrusted = true
})
}
}

View File

@@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { RouterModule, Routes } from '@angular/router'
import { TuiErrorModule, TuiTextfieldControllerModule } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCardModule,
TuiSurfaceModule,
} from '@taiga-ui/experimental'
import { TuiInputPasswordModule } from '@taiga-ui/kit'
import { CAWizardComponent } from './ca-wizard/ca-wizard.component'
import { LoginPage } from './login.page'
const routes: Routes = [
{
path: '',
component: LoginPage,
},
]
@NgModule({
imports: [
CommonModule,
FormsModule,
CAWizardComponent,
TuiButtonModule,
TuiCardModule,
TuiSurfaceModule,
TuiInputPasswordModule,
TuiTextfieldControllerModule,
TuiErrorModule,
RouterModule.forChild(routes),
],
declarations: [LoginPage],
})
export class LoginPageModule {}

View File

@@ -0,0 +1,24 @@
<!-- Local HTTP -->
<ca-wizard *ngIf="config.isLanHttp(); else notLanHttp"></ca-wizard>
<!-- not Local HTTP -->
<ng-template #notLanHttp>
<div tuiCardLarge tuiSurface="elevated" class="card">
<img alt="StartOS Icon" class="logo" src="assets/img/icon.png" />
<h1 class="header">Login to StartOS</h1>
<form (submit)="submit()">
<tui-input-password
tuiTextfieldIconLeft="tuiIconKeyLarge"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="password"
(ngModelChange)="error = ''"
>
Password
</tui-input-password>
<tui-error class="error" [error]="error || null" />
<button tuiButton class="button" appearance="tertiary-solid">
Login
</button>
</form>
</div>
</ng-template>

View File

@@ -0,0 +1,35 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
background: var(--tui-base-02);
}
.card {
@include center-all();
overflow: visible;
align-items: center;
text-align: center;
width: max(33%, 20rem);
}
.logo {
@include center-left();
top: -17%;
width: 6rem;
}
.header {
margin: 2rem 0 1rem;
text-align: center;
font-size: 2rem;
}
.error {
min-height: 2.5rem;
}
.button {
width: 10rem;
border-radius: 10rem;
margin-bottom: 1rem;
}

View File

@@ -0,0 +1,61 @@
import { Component, Inject } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { Router } from '@angular/router'
import { ConfigService } from 'src/app/services/config.service'
import { LoadingService } from '@start9labs/shared'
import { TuiDestroyService } from '@taiga-ui/cdk'
import { takeUntil } from 'rxjs'
import { DOCUMENT } from '@angular/common'
@Component({
selector: 'login',
templateUrl: './login.page.html',
styleUrls: ['./login.page.scss'],
providers: [TuiDestroyService],
})
export class LoginPage {
password = ''
error = ''
constructor(
private readonly destroy$: TuiDestroyService,
private readonly router: Router,
private readonly authService: AuthService,
private readonly loader: LoadingService,
private readonly api: ApiService,
public readonly config: ConfigService,
@Inject(DOCUMENT) public readonly document: Document,
) {}
async submit() {
this.error = ''
const loader = this.loader
.open('Logging in...')
.pipe(takeUntil(this.destroy$))
.subscribe()
try {
this.document.cookie = ''
if (this.password.length > 64) {
this.error = 'Password must be less than 65 characters'
return
}
await this.api.login({
password: this.password,
// TODO: get platforms metadata
metadata: { platforms: [] },
})
this.password = ''
this.authService.setVerified()
this.router.navigate([''], { replaceUrl: true })
} catch (e: any) {
// code 7 is for incorrect password
this.error = e.code === 7 ? 'Invalid Password' : e.message
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,64 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiDataListModule } from '@taiga-ui/core'
import { TuiIconModule } from '@taiga-ui/experimental'
export interface Action {
icon: string
label: string
action: () => void
}
@Component({
selector: 'app-actions',
template: `
<tui-data-list>
<h3 class="title"><ng-content /></h3>
<tui-opt-group
*ngFor="let group of actions | keyvalue: asIsOrder"
[label]="group.key.toUpperCase()"
>
<button
*ngFor="let action of group.value"
tuiOption
class="item"
(click)="action.action()"
>
<tui-icon class="icon" [icon]="action.icon" />
{{ action.label }}
</button>
</tui-opt-group>
</tui-data-list>
`,
styles: [
`
.title {
margin: 0;
padding: 0 0.5rem 0.25rem;
white-space: nowrap;
font: var(--tui-font-text-l);
font-weight: bold;
}
.item {
justify-content: flex-start;
gap: 0.75rem;
}
.icon {
opacity: var(--tui-disabled-opacity);
}
`,
],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiDataListModule, CommonModule, TuiIconModule],
})
export class ActionsComponent {
@Input()
actions: Record<string, readonly Action[]> = {}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -0,0 +1,167 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnInit,
} from '@angular/core'
import { FormGroup, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { CT } from '@start9labs/start-sdk'
import {
tuiMarkControlAsTouchedAndValidate,
TuiValueChangesModule,
} from '@taiga-ui/cdk'
import { TuiDialogContext, TuiModeModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiDialogFormService } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { compare, Operation } from 'fast-json-patch'
import { FormModule } from 'src/app/routes/portal/components/form/form.module'
import { InvalidService } from 'src/app/routes/portal/components/form/invalid.service'
import { FormService } from 'src/app/services/form.service'
export interface ActionButton<T> {
text: string
handler?: (value: T) => Promise<boolean | void> | void
link?: string
}
export interface FormContext<T> {
spec: CT.InputSpec
buttons: ActionButton<T>[]
value?: T
patch?: Operation[]
}
@Component({
standalone: true,
selector: 'app-form',
template: `
<form
[formGroup]="form"
(submit.capture.prevent)="(0)"
(reset.capture.prevent.stop)="onReset()"
(tuiValueChanges)="markAsDirty()"
>
<form-group [spec]="spec" />
<footer tuiMode="onDark">
<ng-content />
<ng-container *ngFor="let button of buttons; let last = last">
<button
*ngIf="button.handler; else link"
tuiButton
[appearance]="last ? 'primary' : 'flat'"
[type]="last ? 'submit' : 'button'"
(click)="onClick(button.handler)"
>
{{ button.text }}
</button>
<ng-template #link>
<a
tuiButton
appearance="flat"
[routerLink]="button.link"
(click)="close()"
>
{{ button.text }}
</a>
</ng-template>
</ng-container>
</footer>
</form>
`,
styles: [
`
footer {
position: sticky;
bottom: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
padding: 1rem 0;
margin: 1rem 0 -1rem;
gap: 1rem;
background: var(--tui-elevation-01);
border-top: 1px solid var(--tui-base-02);
}
`,
],
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
TuiValueChangesModule,
TuiButtonModule,
TuiModeModule,
FormModule,
],
providers: [InvalidService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormComponent<T extends Record<string, any>> implements OnInit {
private readonly dialogFormService = inject(TuiDialogFormService)
private readonly formService = inject(FormService)
private readonly invalidService = inject(InvalidService)
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
POLYMORPHEUS_CONTEXT,
{ optional: true },
)
@Input() spec = this.context?.data.spec || {}
@Input() buttons = this.context?.data.buttons || []
@Input() patch = this.context?.data.patch || []
@Input() value?: T = this.context?.data.value
form = new FormGroup({})
ngOnInit() {
this.dialogFormService.markAsPristine()
this.form = this.formService.createForm(this.spec, this.value)
this.process(this.patch)
}
onReset() {
const { value } = this.form
this.form = this.formService.createForm(this.spec)
this.process(compare(this.form.value, value))
tuiMarkControlAsTouchedAndValidate(this.form)
this.markAsDirty()
}
async onClick(handler: Required<ActionButton<T>>['handler']) {
tuiMarkControlAsTouchedAndValidate(this.form)
this.invalidService.scrollIntoView()
if (this.form.valid && (await handler(this.form.value as T))) {
this.close()
}
}
markAsDirty() {
this.dialogFormService.markAsDirty()
}
close() {
this.context?.$implicit.complete()
}
private process(patch: Operation[]) {
patch.forEach(({ op, path }) => {
const control = this.form.get(path.substring(1).split('/'))
if (!control || !control.parent) return
if (op !== 'remove') {
control.markAsDirty()
control.markAsTouched()
}
control.parent.markAsDirty()
control.parent.markAsTouched()
})
}
}

View File

@@ -0,0 +1,30 @@
import { Directive, ElementRef, inject, OnDestroy, OnInit } from '@angular/core'
import { ControlContainer, NgControl } from '@angular/forms'
import { InvalidService } from './invalid.service'
@Directive({
selector: 'form-control, form-array, form-object',
})
export class ControlDirective implements OnInit, OnDestroy {
private readonly invalidService = inject(InvalidService, { optional: true })
private readonly element: ElementRef<HTMLElement> = inject(ElementRef)
private readonly control =
inject(NgControl, { optional: true }) ||
inject(ControlContainer, { optional: true })
get invalid(): boolean {
return !!this.control?.invalid
}
scrollIntoView() {
this.element.nativeElement.scrollIntoView({ behavior: 'smooth' })
}
ngOnInit() {
this.invalidService?.add(this)
}
ngOnDestroy() {
this.invalidService?.remove(this)
}
}

View File

@@ -0,0 +1,35 @@
import { inject } from '@angular/core'
import { FormControlComponent } from './form-control/form-control.component'
import { CT } from '@start9labs/start-sdk'
export abstract class Control<Spec extends CT.ValueSpec, Value> {
private readonly control: FormControlComponent<Spec, Value> =
inject(FormControlComponent)
get invalid(): boolean {
return this.control.touched && this.control.invalid
}
get spec(): Spec {
return this.control.spec
}
// TODO: Properly handle already set immutable value
get readOnly(): boolean {
return (
!!this.value && !!this.control.control?.pristine && this.control.immutable
)
}
get value(): Value | null {
return this.control.value
}
set value(value: Value | null) {
this.control.onInput(value)
}
onFocus(focused: boolean) {
this.control.onFocus(focused)
}
}

View File

@@ -0,0 +1,58 @@
<div class="label">
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description || spec.disabled"
[content]="spec | hint"
></tui-tooltip>
<button
tuiLink
type="button"
class="add"
[disabled]="!canAdd"
(click)="add()"
>
+ Add
</button>
</div>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<ng-container *ngFor="let item of array.control.controls; let index = index">
<form-object
*ngIf="spec.spec.type === 'object'; else control"
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache: $any(spec.spec).displayAs }}
<ng-container *ngTemplateOutlet="remove"></ng-container>
</form-object>
<ng-template #control>
<form-control
class="control"
tuiTextfieldSize="m"
[tuiTextfieldLabelOutside]="true"
[tuiTextfieldIcon]="remove"
[formControl]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
></form-control>
</ng-template>
<ng-template #remove>
<button
tuiIconButton
type="button"
class="remove"
iconLeft="tuiIconTrash"
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt(index)"
></button>
</ng-template>
</ng-container>

View File

@@ -0,0 +1,50 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: block;
margin: 2rem 0;
}
.label {
display: flex;
font-size: 1.25rem;
font-weight: bold;
}
.add {
font-size: 1rem;
padding: 0 1rem;
margin-left: auto;
}
.object {
display: block;
position: relative;
&_open::after,
&:last-child::after {
opacity: 0;
}
&:after {
@include transition(opacity);
content: '';
position: absolute;
bottom: -0.5rem;
height: 1px;
left: 3rem;
right: 1rem;
background: var(--tui-clear);
}
}
.remove {
margin-left: auto;
pointer-events: auto;
}
.control {
display: block;
margin: 0.5rem 0;
}

View File

@@ -0,0 +1,91 @@
import { Component, HostBinding, inject, Input } from '@angular/core'
import { AbstractControl, FormArrayName } from '@angular/forms'
import { TUI_PARENT_STOP, TuiDestroyService } from '@taiga-ui/cdk'
import {
TUI_ANIMATION_OPTIONS,
TuiDialogService,
tuiFadeIn,
tuiHeightCollapse,
} from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { CT } from '@start9labs/start-sdk'
import { filter, takeUntil } from 'rxjs'
import { FormService } from 'src/app/services/form.service'
import { ERRORS } from '../form-group/form-group.component'
@Component({
selector: 'form-array',
templateUrl: './form-array.component.html',
styleUrls: ['./form-array.component.scss'],
animations: [tuiFadeIn, tuiHeightCollapse, TUI_PARENT_STOP],
providers: [TuiDestroyService],
})
export class FormArrayComponent {
@Input({ required: true })
spec!: CT.ValueSpecList
@HostBinding('@tuiParentStop')
readonly animation = { value: '', ...inject(TUI_ANIMATION_OPTIONS) }
readonly order = ERRORS
readonly array = inject(FormArrayName)
readonly open = new Map<AbstractControl, boolean>()
private warned = false
private readonly formService = inject(FormService)
private readonly dialogs = inject(TuiDialogService)
private readonly destroy$ = inject(TuiDestroyService)
get canAdd(): boolean {
return (
!this.spec.disabled &&
(!this.spec.maxLength ||
this.spec.maxLength >= this.array.control.controls.length)
)
}
add() {
if (!this.warned && this.spec.warning) {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: { content: this.spec.warning, yes: 'Ok', no: 'Cancel' },
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.addItem()
})
} else {
this.addItem()
}
this.warned = true
}
removeAt(index: number) {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Confirm',
size: 's',
data: {
content: 'Are you sure you want to delete this entry?',
yes: 'Delete',
no: 'Cancel',
},
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.removeItem(index)
})
}
private removeItem(index: number) {
this.open.delete(this.array.control.at(index))
this.array.control.removeAt(index)
}
private addItem() {
this.array.control.insert(0, this.formService.getListItem(this.spec))
this.open.set(this.array.control.at(0), true)
}
}

View File

@@ -0,0 +1,32 @@
<tui-input
[maskito]="mask"
[tuiTextfieldCustomContent]="color"
[tuiTextfieldCleaner]="false"
[tuiHintContent]="spec | hint"
[readOnly]="readOnly"
[disabled]="!!spec.disabled"
[pseudoInvalid]="invalid"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input>
<ng-template #color>
<div class="wrapper" [style.color]="value">
<input
*ngIf="!readOnly && !spec.disabled"
type="color"
class="color"
tabindex="-1"
[(ngModel)]="value"
(click.stop)="(0)"
/>
<tui-svg
src="tuiIconPaintLarge"
tuiWrapper
appearance="icon"
class="icon"
></tui-svg>
</div>
</ng-template>

View File

@@ -0,0 +1,33 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
.wrapper {
position: relative;
width: 1.5rem;
height: 1.5rem;
pointer-events: auto;
&::after {
content: '';
position: absolute;
height: 0.3rem;
width: 1.4rem;
bottom: 0.125rem;
background: currentColor;
border-radius: 0.125rem;
pointer-events: none;
}
}
.color {
@include fullsize();
opacity: 0;
}
.icon {
@include fullsize();
pointer-events: none;
input:hover + & {
opacity: 1;
}
}

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
import { MaskitoOptions } from '@maskito/core'
@Component({
selector: 'form-color',
templateUrl: './form-color.component.html',
styleUrls: ['./form-color.component.scss'],
})
export class FormColorComponent extends Control<CT.ValueSpecColor, string> {
readonly mask: MaskitoOptions = {
mask: ['#', ...Array(6).fill(/[0-9a-f]/i)],
}
}

View File

@@ -0,0 +1,40 @@
<ng-container [ngSwitch]="spec.type">
<form-color *ngSwitchCase="'color'"></form-color>
<form-datetime *ngSwitchCase="'datetime'"></form-datetime>
<form-file *ngSwitchCase="'file'"></form-file>
<form-multiselect *ngSwitchCase="'multiselect'"></form-multiselect>
<form-number *ngSwitchCase="'number'"></form-number>
<form-select *ngSwitchCase="'select'"></form-select>
<form-text *ngSwitchCase="'text'"></form-text>
<form-textarea *ngSwitchCase="'textarea'"></form-textarea>
<form-toggle *ngSwitchCase="'toggle'"></form-toggle>
</ng-container>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<ng-template
*ngIf="spec.warning || immutable"
#warning
let-completeWith="completeWith"
>
{{ spec.warning }}
<p *ngIf="immutable">This value cannot be changed once set!</p>
<div class="buttons">
<button
tuiButton
type="button"
appearance="secondary"
size="s"
(click)="completeWith(true)"
>
Rollback
</button>
<button
tuiButton
type="button"
appearance="flat"
size="s"
(click)="completeWith(false)"
>
Accept
</button>
</div>
</ng-template>

View File

@@ -0,0 +1,11 @@
:host {
display: block;
}
.buttons {
margin-top: 0.5rem;
:first-child {
margin-right: 0.5rem;
}
}

View File

@@ -0,0 +1,71 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
TemplateRef,
ViewChild,
} from '@angular/core'
import { AbstractTuiNullableControl } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiDialogContext,
TuiNotification,
} from '@taiga-ui/core'
import { filter, takeUntil } from 'rxjs'
import { CT } from '@start9labs/start-sdk'
import { ERRORS } from '../form-group/form-group.component'
import { FORM_CONTROL_PROVIDERS } from './form-control.providers'
@Component({
selector: 'form-control',
templateUrl: './form-control.component.html',
styleUrls: ['./form-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: FORM_CONTROL_PROVIDERS,
})
export class FormControlComponent<
T extends CT.ValueSpec,
V,
> extends AbstractTuiNullableControl<V> {
@Input({ required: true })
spec!: T
@ViewChild('warning')
warning?: TemplateRef<TuiDialogContext<boolean>>
warned = false
focused = false
readonly order = ERRORS
private readonly alerts = inject(TuiAlertService)
get immutable(): boolean {
return 'immutable' in this.spec && this.spec.immutable
}
onFocus(focused: boolean) {
this.focused = focused
this.updateFocused(focused)
}
onInput(value: V | null) {
const previous = this.value
if (!this.warned && this.warning) {
this.alerts
.open<boolean>(this.warning, {
label: 'Warning',
status: TuiNotification.Warning,
hasCloseButton: false,
autoClose: false,
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.value = previous
})
}
this.warned = true
this.value = value === '' ? null : value
}
}

View File

@@ -0,0 +1,25 @@
import { forwardRef, Provider } from '@angular/core'
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
import { CT } from '@start9labs/start-sdk'
import { FormControlComponent } from './form-control.component'
interface ValidatorsPatternError {
actualValue: string
requiredPattern: string | RegExp
}
export const FORM_CONTROL_PROVIDERS: Provider[] = [
{
provide: TUI_VALIDATION_ERRORS,
deps: [forwardRef(() => FormControlComponent)],
useFactory: (control: FormControlComponent<CT.ValueSpec, string>) => ({
required: 'Required',
pattern: ({ requiredPattern }: ValidatorsPatternError) =>
('patterns' in control.spec &&
control.spec.patterns.find(
({ regex }) => String(regex) === String(requiredPattern),
)?.description) ||
'Invalid format',
}),
},
]

View File

@@ -0,0 +1,43 @@
<ng-container [ngSwitch]="spec.inputmode" [tuiHintContent]="spec.description">
<tui-input-time
*ngSwitchCase="'time'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[ngModel]="getTime(value)"
(ngModelChange)="value = $event?.toString() || null"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-time>
<tui-input-date
*ngSwitchCase="'date'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper: getLimit)[0] : min"
[max]="spec.max ? (spec.max | tuiMapper: getLimit)[0] : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-date>
<tui-input-date-time
*ngSwitchCase="'datetime-local'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper: getLimit) : min"
[max]="spec.max ? (spec.max | tuiMapper: getLimit) : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-date-time>
</ng-container>

View File

@@ -0,0 +1,36 @@
import { Component } from '@angular/core'
import {
TUI_FIRST_DAY,
TUI_LAST_DAY,
TuiDay,
tuiPure,
TuiTime,
} from '@taiga-ui/cdk'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-datetime',
templateUrl: './form-datetime.component.html',
})
export class FormDatetimeComponent extends Control<
CT.ValueSpecDatetime,
string
> {
readonly min = TUI_FIRST_DAY
readonly max = TUI_LAST_DAY
@tuiPure
getTime(value: string | null) {
return value ? TuiTime.fromString(value) : null
}
getLimit(limit: string): [TuiDay, TuiTime] {
return [
TuiDay.jsonParse(limit.slice(0, 10)),
limit.length === 10
? new TuiTime(0, 0)
: TuiTime.fromString(limit.slice(-5)),
]
}
}

View File

@@ -0,0 +1,31 @@
<tui-input-files
[pseudoInvalid]="invalid"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
<input tuiInputFiles [accept]="spec.extensions.join(',')" />
<ng-template let-drop>
<div class="template" [class.template_hidden]="drop">
<div class="label">
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<tui-tooltip
*ngIf="spec.description"
[content]="spec.description"
></tui-tooltip>
</div>
<tui-tag
*ngIf="value; else label"
class="file"
size="l"
[value]="value.name"
[removable]="true"
(edited)="value = null"
></tui-tag>
<ng-template #label>
<small>Click or drop file here</small>
</ng-template>
</div>
<div class="drop" [class.drop_hidden]="!drop">Drop file here</div>
</ng-template>
</tui-input-files>

View File

@@ -0,0 +1,46 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
.template {
@include transition(opacity);
width: 100%;
display: flex;
align-items: center;
padding: 0 0.5rem;
font: var(--tui-font-text-m);
font-weight: bold;
&_hidden {
opacity: 0;
}
}
.drop {
@include fullsize();
@include transition(opacity);
display: flex;
align-items: center;
justify-content: space-around;
&_hidden {
opacity: 0;
}
}
.label {
display: flex;
align-items: center;
max-width: 50%;
}
small {
max-width: 50%;
font-weight: normal;
color: var(--tui-text-02);
margin-left: auto;
}
tui-tag {
z-index: 1;
margin: -0.25rem -0.25rem -0.25rem auto;
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core'
import { TuiFileLike } from '@taiga-ui/kit'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-file',
templateUrl: './form-file.component.html',
styleUrls: ['./form-file.component.scss'],
})
export class FormFileComponent extends Control<CT.ValueSpecFile, TuiFileLike> {}

View File

@@ -0,0 +1,30 @@
<ng-container
*ngFor="let entry of spec | keyvalue: asIsOrder"
tuiMode="onDark"
[ngSwitch]="entry.value.type"
[tuiTextfieldCleaner]="true"
>
<form-object
*ngSwitchCase="'object'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-object>
<form-union
*ngSwitchCase="'union'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-union>
<form-array
*ngSwitchCase="'list'"
[formArrayName]="entry.key"
[spec]="$any(entry.value)"
></form-array>
<form-control
*ngSwitchDefault
class="g-form-control"
[formControlName]="entry.key"
[spec]="entry.value"
></form-control>
</ng-container>

View File

@@ -0,0 +1,35 @@
form-group .g-form-control:not(:first-child) {
margin-top: 1rem;
}
form-group .g-form-group {
position: relative;
padding-left: var(--tui-height-m);
&::before,
&::after {
content: '';
position: absolute;
background: var(--tui-clear);
}
&::before {
top: 0;
left: calc(1rem - 1px);
bottom: 0.5rem;
width: 2px;
}
&::after {
left: 0.75rem;
bottom: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
}
}
form-group tui-tooltip {
z-index: 1;
margin-left: 0.25rem;
}

View File

@@ -0,0 +1,35 @@
import {
ChangeDetectionStrategy,
Component,
Input,
ViewEncapsulation,
} from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { FORM_GROUP_PROVIDERS } from './form-group.providers'
export const ERRORS = [
'required',
'pattern',
'notNumber',
'numberNotInteger',
'numberNotInRange',
'listNotUnique',
'listNotInRange',
'listItemIssue',
]
@Component({
selector: 'form-group',
templateUrl: './form-group.component.html',
styleUrls: ['./form-group.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [FORM_GROUP_PROVIDERS],
})
export class FormGroupComponent {
@Input() spec: CT.InputSpec = {}
asIsOrder() {
return 0
}
}

View File

@@ -0,0 +1,34 @@
import { Provider, SkipSelf } from '@angular/core'
import {
TUI_ARROW_MODE,
tuiInputDateOptionsProvider,
tuiInputTimeOptionsProvider,
} from '@taiga-ui/kit'
import { TUI_DEFAULT_ERROR_MESSAGE } from '@taiga-ui/core'
import { ControlContainer } from '@angular/forms'
import { identity, of } from 'rxjs'
export const FORM_GROUP_PROVIDERS: Provider[] = [
{
provide: TUI_DEFAULT_ERROR_MESSAGE,
useValue: of('Unknown error'),
},
{
provide: ControlContainer,
deps: [[new SkipSelf(), ControlContainer]],
useFactory: identity,
},
{
provide: TUI_ARROW_MODE,
useValue: {
interactive: null,
disabled: null,
},
},
tuiInputDateOptionsProvider({
nativePicker: true,
}),
tuiInputTimeOptionsProvider({
nativePicker: true,
}),
]

View File

@@ -0,0 +1,18 @@
<tui-multi-select
[tuiHintContent]="spec | hint"
[disabled]="disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[editable]="false"
[disabledItemHandler]="disabledItemHandler"
[(ngModel)]="selected"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<select
tuiSelect
multiple
[items]="items"
[disabledItemHandler]="disabledItemHandler"
></select>
</tui-multi-select>

View File

@@ -0,0 +1,49 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
import { tuiPure } from '@taiga-ui/cdk'
import { invert } from '@start9labs/shared'
@Component({
selector: 'form-multiselect',
templateUrl: './form-multiselect.component.html',
})
export class FormMultiselectComponent extends Control<
CT.ValueSpecMultiselect,
readonly string[]
> {
private readonly inverted = invert(this.spec.values)
private readonly isDisabled = (item: string) =>
Array.isArray(this.spec.disabled) &&
this.spec.disabled.includes(this.inverted[item])
private readonly isExceedingLimit = (item: string) =>
!!this.spec.maxLength &&
this.selected.length >= this.spec.maxLength &&
!this.selected.includes(item)
readonly disabledItemHandler = (item: string): boolean =>
this.isDisabled(item) || this.isExceedingLimit(item)
readonly items = Object.values(this.spec.values)
get disabled(): boolean {
return typeof this.spec.disabled === 'string'
}
get selected(): string[] {
return this.memoize(this.value)
}
set selected(value: string[]) {
this.value = Object.entries(this.spec.values)
.filter(([_, v]) => value.includes(v))
.map(([k]) => k)
}
@tuiPure
private memoize(value: null | readonly string[]): string[] {
return value?.map(key => this.spec.values[key]) || []
}
}

View File

@@ -0,0 +1,18 @@
<tui-input-number
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[tuiTextfieldPostfix]="spec.units || ''"
[pseudoInvalid]="invalid"
[precision]="Infinity"
[decimal]="spec.integer ? 'never' : 'not-zero'"
[min]="spec.min ?? -Infinity"
[max]="spec.max ?? Infinity"
[step]="spec.step || 0"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<input tuiTextfield [placeholder]="spec.placeholder || ''" />
</tui-input-number>

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-number',
templateUrl: './form-number.component.html',
})
export class FormNumberComponent extends Control<CT.ValueSpecNumber, number> {
protected readonly Infinity = Infinity
}

View File

@@ -0,0 +1,25 @@
<h3 class="title" (click)="toggle()">
<button
tuiIconButton
size="s"
iconLeft="tuiIconChevronDown"
type="button"
class="button"
[class.button_open]="open"
[style.border-radius.%]="100"
[appearance]="invalid ? 'secondary-destructive' : 'secondary'"
></button>
<ng-content></ng-content>
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description"
[content]="spec.description"
(click.stop)="(0)"
></tui-tooltip>
</h3>
<tui-expand class="expand" [expanded]="open">
<div class="g-form-group" [class.g-form-group_invalid]="invalid">
<form-group [spec]="spec.spec"></form-group>
</div>
</tui-expand>

View File

@@ -0,0 +1,41 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.title {
position: relative;
height: var(--tui-height-l);
display: flex;
align-items: center;
cursor: pointer;
font: var(--tui-font-text-l);
font-weight: bold;
margin: 0 0 -0.75rem;
}
.button {
@include transition(transform);
margin-right: 1rem;
&_open {
transform: rotate(180deg);
}
}
.expand {
align-self: stretch;
}
.g-form-group {
padding-top: 0.75rem;
&_invalid::before,
&_invalid::after {
background: var(--tui-error-bg);
}
}

View File

@@ -0,0 +1,38 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
} from '@angular/core'
import { ControlContainer } from '@angular/forms'
import { CT } from '@start9labs/start-sdk'
@Component({
selector: 'form-object',
templateUrl: './form-object.component.html',
styleUrls: ['./form-object.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormObjectComponent {
@Input({ required: true })
spec!: CT.ValueSpecObject
@Input()
open = false
@Output()
readonly openChange = new EventEmitter<boolean>()
private readonly container = inject(ControlContainer)
get invalid() {
return !this.container.valid && this.container.touched
}
toggle() {
this.open = !this.open
this.openChange.emit(this.open)
}
}

View File

@@ -0,0 +1,17 @@
<tui-select
[tuiHintContent]="spec | hint"
[disabled]="disabled"
[readOnly]="readOnly"
[tuiTextfieldCleaner]="!spec.required"
[pseudoInvalid]="invalid"
[(ngModel)]="selected"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<select
tuiSelect
[items]="items"
[disabledItemHandler]="disabledItemHandler"
></select>
</tui-select>

View File

@@ -0,0 +1,30 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { invert } from '@start9labs/shared'
import { Control } from '../control'
@Component({
selector: 'form-select',
templateUrl: './form-select.component.html',
})
export class FormSelectComponent extends Control<CT.ValueSpecSelect, string> {
private readonly inverted = invert(this.spec.values)
readonly items = Object.values(this.spec.values)
readonly disabledItemHandler = (item: string) =>
Array.isArray(this.spec.disabled) &&
this.spec.disabled.includes(this.inverted[item])
get disabled(): boolean {
return typeof this.spec.disabled === 'string'
}
get selected(): string | null {
return (this.value && this.spec.values[this.value]) || null
}
set selected(value: string | null) {
this.value = (value && this.inverted[value]) || null
}
}

View File

@@ -0,0 +1,44 @@
<tui-input
[tuiTextfieldCustomContent]="spec.masked || spec.generate ? toggle : ''"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<input
tuiTextfield
[class.masked]="spec.masked && masked"
[placeholder]="spec.placeholder || ''"
[attr.minLength]="spec.minLength"
[attr.maxLength]="spec.maxLength"
[attr.inputmode]="spec.inputmode"
/>
</tui-input>
<ng-template #toggle>
<button
*ngIf="spec.generate"
tuiIconButton
type="button"
appearance="icon"
title="Generate"
size="xs"
class="button"
iconLeft="tuiIconRefreshCcw"
(click)="generate()"
></button>
<button
*ngIf="spec.masked"
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
(click)="masked = !masked"
></button>
</ng-template>

View File

@@ -0,0 +1,8 @@
.button {
pointer-events: auto;
margin-left: 0.25rem;
}
.masked {
-webkit-text-security: disc;
}

View File

@@ -0,0 +1,17 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
import { getDefaultString } from 'src/app/utils/config-utilities'
@Component({
selector: 'form-text',
templateUrl: './form-text.component.html',
styleUrls: ['./form-text.component.scss'],
})
export class FormTextComponent extends Control<CT.ValueSpecText, string> {
masked = true
generate() {
this.value = getDefaultString(this.spec.generate || '')
}
}

View File

@@ -0,0 +1,15 @@
<tui-text-area
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[expandable]="true"
[rows]="6"
[maxLength]="spec.maxLength"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea>
</tui-text-area>

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-textarea',
templateUrl: './form-textarea.component.html',
})
export class FormTextareaComponent extends Control<
CT.ValueSpecTextarea,
string
> {}

View File

@@ -0,0 +1,11 @@
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description || spec.disabled"
[tuiHintContent]="spec | hint"
></tui-tooltip>
<tui-toggle
size="l"
[disabled]="!!spec.disabled || readOnly"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
></tui-toggle>

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-toggle',
templateUrl: './form-toggle.component.html',
host: { class: 'g-toggle' },
})
export class FormToggleComponent extends Control<CT.ValueSpecToggle, boolean> {}

View File

@@ -0,0 +1,11 @@
<form-control
[spec]="selectSpec"
[formControlName]="select"
(tuiValueChanges)="onUnion($event)"
></form-control>
<tui-elastic-container class="g-form-group" [formGroupName]="value">
<form-group
class="group"
[spec]="(union && spec.variants[union].spec) || {}"
></form-group>
</tui-elastic-container>

View File

@@ -0,0 +1,8 @@
:host {
display: block;
}
.group {
display: block;
margin-top: 1rem;
}

View File

@@ -0,0 +1,58 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnChanges,
} from '@angular/core'
import { ControlContainer, FormGroupName } from '@angular/forms'
import { CT } from '@start9labs/start-sdk'
import { FormService } from 'src/app/services/form.service'
import { tuiPure } from '@taiga-ui/cdk'
@Component({
selector: 'form-union',
templateUrl: './form-union.component.html',
styleUrls: ['./form-union.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [
{
provide: ControlContainer,
useExisting: FormGroupName,
},
],
})
export class FormUnionComponent implements OnChanges {
@Input({ required: true })
spec!: CT.ValueSpecUnion
selectSpec!: CT.ValueSpecSelect
readonly select = CT.unionSelectKey
readonly value = CT.unionValueKey
private readonly form = inject(FormGroupName)
private readonly formService = inject(FormService)
get union(): string {
return this.form.value[CT.unionSelectKey]
}
@tuiPure
onUnion(union: string) {
this.form.control.setControl(
CT.unionValueKey,
this.formService.getFormGroup(
union ? this.spec.variants[union].spec : {},
),
{
emitEvent: false,
},
)
}
ngOnChanges() {
this.selectSpec = this.formService.getUnionSelectSpec(this.spec, this.union)
if (this.union) this.onUnion(this.union)
}
}

View File

@@ -0,0 +1,107 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { MaskitoModule } from '@maskito/angular'
import { TuiMapperPipeModule, TuiValueChangesModule } from '@taiga-ui/cdk'
import {
TuiErrorModule,
TuiExpandModule,
TuiHintModule,
TuiLinkModule,
TuiModeModule,
TuiSvgModule,
TuiTextfieldControllerModule,
TuiTooltipModule,
TuiWrapperModule,
} from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import {
TuiElasticContainerModule,
TuiFieldErrorPipeModule,
TuiInputDateModule,
TuiInputDateTimeModule,
TuiInputFilesModule,
TuiInputModule,
TuiInputNumberModule,
TuiInputTimeModule,
TuiMultiSelectModule,
TuiPromptModule,
TuiSelectModule,
TuiTagModule,
TuiTextAreaModule,
TuiToggleModule,
} from '@taiga-ui/kit'
import { FormGroupComponent } from './form-group/form-group.component'
import { FormTextComponent } from './form-text/form-text.component'
import { FormToggleComponent } from './form-toggle/form-toggle.component'
import { FormTextareaComponent } from './form-textarea/form-textarea.component'
import { FormNumberComponent } from './form-number/form-number.component'
import { FormSelectComponent } from './form-select/form-select.component'
import { FormFileComponent } from './form-file/form-file.component'
import { FormMultiselectComponent } from './form-multiselect/form-multiselect.component'
import { FormUnionComponent } from './form-union/form-union.component'
import { FormObjectComponent } from './form-object/form-object.component'
import { FormArrayComponent } from './form-array/form-array.component'
import { FormControlComponent } from './form-control/form-control.component'
import { MustachePipe } from './mustache.pipe'
import { ControlDirective } from './control.directive'
import { FormColorComponent } from './form-color/form-color.component'
import { FormDatetimeComponent } from './form-datetime/form-datetime.component'
import { HintPipe } from './hint.pipe'
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
TuiInputModule,
TuiInputNumberModule,
TuiInputFilesModule,
TuiTextAreaModule,
TuiSelectModule,
TuiMultiSelectModule,
TuiToggleModule,
TuiTooltipModule,
TuiHintModule,
TuiModeModule,
TuiTagModule,
TuiButtonModule,
TuiExpandModule,
TuiTextfieldControllerModule,
TuiLinkModule,
TuiPromptModule,
TuiErrorModule,
TuiFieldErrorPipeModule,
TuiValueChangesModule,
TuiElasticContainerModule,
MaskitoModule,
TuiSvgModule,
TuiWrapperModule,
TuiInputDateModule,
TuiInputTimeModule,
TuiInputDateTimeModule,
TuiMapperPipeModule,
],
declarations: [
FormGroupComponent,
FormControlComponent,
FormColorComponent,
FormDatetimeComponent,
FormTextComponent,
FormToggleComponent,
FormTextareaComponent,
FormNumberComponent,
FormSelectComponent,
FormMultiselectComponent,
FormFileComponent,
FormUnionComponent,
FormObjectComponent,
FormArrayComponent,
MustachePipe,
HintPipe,
ControlDirective,
],
exports: [FormGroupComponent],
})
export class FormModule {}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
@Pipe({
name: 'hint',
})
export class HintPipe implements PipeTransform {
transform(spec: CT.ValueSpec): string {
const hint = []
if (spec.description) {
hint.push(spec.description)
}
if ('disabled' in spec && typeof spec.disabled === 'string') {
hint.push(`Disabled: ${spec.disabled}`)
}
return hint.join('\n\n')
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core'
import { ControlDirective } from './control.directive'
@Injectable()
export class InvalidService {
private readonly controls: ControlDirective[] = []
scrollIntoView() {
this.controls.find(({ invalid }) => invalid)?.scrollIntoView()
}
add(control: ControlDirective) {
this.controls.push(control)
}
remove(control: ControlDirective) {
this.controls.splice(this.controls.indexOf(control), 1)
}
}

View File

@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core'
const Mustache = require('mustache')
@Pipe({
name: 'mustache',
})
export class MustachePipe implements PipeTransform {
transform(value: any, displayAs: string): string {
return displayAs && Mustache.render(displayAs, value)
}
}

View File

@@ -0,0 +1,70 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { CopyService, EmverPipesModule } from '@start9labs/shared'
import {
TuiButtonModule,
TuiCellModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ConfigService } from 'src/app/services/config.service'
@Component({
template: `
<ng-container *ngIf="server$ | async as server">
<div tuiCell>
<div tuiTitle>
<strong>Version</strong>
<div tuiSubtitle>{{ server.version | displayEmver }}</div>
</div>
</div>
<div tuiCell>
<div tuiTitle>
<strong>Git Hash</strong>
<div tuiSubtitle>{{ gitHash }}</div>
</div>
<button
tuiIconButton
appearance="icon"
iconLeft="tuiIconCopy"
(click)="copyService.copy(gitHash)"
>
Copy
</button>
</div>
<div tuiCell>
<div tuiTitle>
<strong>CA fingerprint</strong>
<div tuiSubtitle>{{ server.caFingerprint }}</div>
</div>
<button
tuiIconButton
appearance="icon"
iconLeft="tuiIconCopy"
(click)="copyService.copy(server.caFingerprint)"
>
Copy
</button>
</div>
</ng-container>
`,
styles: ['[tuiCell] { padding-inline: 0 }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
EmverPipesModule,
TuiTitleModule,
TuiButtonModule,
TuiCellModule,
],
})
export class AboutComponent {
readonly server$ = inject(PatchDB<DataModel>).watch$('serverInfo')
readonly copyService = inject(CopyService)
readonly gitHash = inject(ConfigService).gitHash
}
export const ABOUT = new PolymorpheusComponent(AboutComponent)

View File

@@ -0,0 +1,91 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
inject,
Input,
} from '@angular/core'
import { Breadcrumb } from 'src/app/services/breadcrumbs.service'
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
import {
TUI_ANIMATION_OPTIONS,
tuiFadeIn,
tuiWidthCollapse,
} from '@taiga-ui/core'
@Component({
standalone: true,
selector: 'a[headerBreadcrumb]',
template: `
@if (item.icon?.startsWith('tuiIcon')) {
<tui-icon [icon]="item.icon || ''" />
} @else if (item.icon) {
<img [style.width.rem]="2" [src]="item.icon" [alt]="item.title" />
}
<span tuiTitle>
{{ item.title }}
@if (item.subtitle) {
<span tuiSubtitle="">{{ item.subtitle }}</span>
}
</span>
<ng-content />
`,
styles: [
`
:host {
display: flex;
align-items: center;
gap: 1rem;
min-width: 1.25rem;
white-space: nowrap;
text-transform: capitalize;
--clip-path: polygon(
calc(100% - 1.75rem) 0%,
calc(100% - 0.875rem) 50%,
100% 100%,
0% 100%,
0.875rem 50%,
0% 0%
);
&:not(.active) {
--clip-path: polygon(
calc(100% - 1.75rem) 0%,
calc(100% - 0.875rem) 50%,
calc(100% - 1.75rem) 100%,
0% 100%,
0.875rem 50%,
0% 0%
);
}
& > * {
font-weight: bold;
gap: 0;
border-radius: 100%;
}
&::before,
&::after {
content: '';
margin: 0.5rem;
}
&::before {
margin: 0.25rem;
}
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIconModule, TuiTitleModule],
animations: [tuiWidthCollapse, tuiFadeIn],
})
export class HeaderBreadcrumbComponent {
@Input({ required: true, alias: 'headerBreadcrumb' })
item!: Breadcrumb
@HostBinding('@tuiFadeIn')
@HostBinding('@tuiWidthCollapse')
readonly animation = inject(TUI_ANIMATION_OPTIONS)
}

View File

@@ -0,0 +1,94 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiIconModule } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map, Observable, startWith } from 'rxjs'
import { ConnectionService } from 'src/app/services/connection.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
standalone: true,
selector: 'header-connection',
template: `
<ng-content />
@if (connection$ | async; as connection) {
<!-- data-connection is used to display color indicator in the header through :has() -->
<tui-icon
[icon]="connection.icon"
[style.color]="connection.color"
[style.font-size.em]="1.5"
[attr.data-connection]="connection.status"
></tui-icon>
{{ connection.message }}
}
`,
styles: [
`
:host {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 2rem;
}
:host-context(tui-root._mobile) {
display: none;
font-size: 1rem;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIconModule, AsyncPipe],
})
export class HeaderConnectionComponent {
readonly connection$: Observable<{
message: string
color: string
icon: string
status: string
}> = combineLatest([
inject(ConnectionService).networkConnected$,
inject(ConnectionService).websocketConnected$.pipe(startWith(false)),
inject(PatchDB<DataModel>)
.watch$('serverInfo', 'statusInfo')
.pipe(startWith({ restarting: false, shuttingDown: false })),
]).pipe(
map(([network, websocket, status]) => {
if (!network)
return {
message: 'No Internet',
color: 'var(--tui-error-fill)',
icon: 'tuiIconCloudOff',
status: 'error',
}
if (!websocket)
return {
message: 'Connecting',
color: 'var(--tui-warning-fill)',
icon: 'tuiIconCloudOff',
status: 'warning',
}
if (status.shuttingDown)
return {
message: 'Shutting Down',
color: 'var(--tui-neutral-fill)',
icon: 'tuiIconPower',
status: 'neutral',
}
if (status.restarting)
return {
message: 'Restarting',
color: 'var(--tui-neutral-fill)',
icon: 'tuiIconPower',
status: 'neutral',
}
return {
message: 'Connected',
color: 'var(--tui-success-fill)',
icon: 'tuiIconCloud',
status: 'success',
}
}),
)
}

View File

@@ -0,0 +1,112 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
ElementRef,
HostListener,
inject,
ViewChild,
} from '@angular/core'
import { Router } from '@angular/router'
import { TuiSidebarModule } from '@taiga-ui/addon-mobile'
import { tuiContainsOrAfter, tuiIsElement, TuiLetModule } from '@taiga-ui/cdk'
import {
TuiBadgedContentModule,
TuiBadgeNotificationModule,
TuiButtonModule,
} from '@taiga-ui/experimental'
import { Subject } from 'rxjs'
import { HeaderMenuComponent } from './menu.component'
import { HeaderNotificationsComponent } from './notifications.component'
import { SidebarDirective } from 'src/app/components/sidebar-host.component'
import { NotificationService } from 'src/app/services/notification.service'
@Component({
standalone: true,
selector: 'header-corner',
template: `
<ng-content />
<tui-badged-content
*tuiLet="notificationService.unreadCount$ | async as unread"
[style.--tui-radius.%]="50"
>
<tui-badge-notification *ngIf="unread" tuiSlot="top" size="s">
{{ unread }}
</tui-badge-notification>
<button
tuiIconButton
iconLeft="tuiIconBellLarge"
appearance="icon"
size="s"
[style.color]="'var(--tui-text-01)'"
(click)="handleNotificationsClick(unread || 0)"
>
Notifications
</button>
</tui-badged-content>
<header-menu></header-menu>
<header-notifications
(onEmpty)="this.open$.next(false)"
*tuiSidebar="!!(open$ | async); direction: 'right'; autoWidth: true"
/>
`,
styles: [
`
:host {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.5rem 0 1.75rem;
--clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 1.75rem 100%);
}
:host-context(tui-root._mobile) tui-badged-content {
display: none;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
HeaderMenuComponent,
HeaderNotificationsComponent,
SidebarDirective,
TuiBadgeNotificationModule,
TuiBadgedContentModule,
TuiButtonModule,
TuiLetModule,
TuiSidebarModule,
],
})
export class HeaderCornerComponent {
private readonly router = inject(Router)
readonly notificationService = inject(NotificationService)
@ViewChild(HeaderNotificationsComponent, { read: ElementRef })
private readonly panel?: ElementRef<HTMLElement>
private readonly _ = this.router.events.subscribe(() => {
this.open$.next(false)
})
readonly open$ = new Subject<boolean>()
@HostListener('document:click.capture', ['$event.target'])
onClick(target: EventTarget | null) {
if (
tuiIsElement(target) &&
this.panel?.nativeElement &&
!tuiContainsOrAfter(this.panel.nativeElement, target)
) {
this.open$.next(false)
}
}
handleNotificationsClick(unread: number) {
if (unread) {
this.open$.next(true)
} else {
this.router.navigateByUrl('/portal/system/notifications')
}
}
}

View File

@@ -0,0 +1,181 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
IsActiveMatchOptions,
RouterLink,
RouterLinkActive,
} from '@angular/router'
import { PatchDB } from 'patch-db-client'
import { HeaderConnectionComponent } from './connection.component'
import { HeaderHomeComponent } from './home.component'
import { HeaderCornerComponent } from './corner.component'
import { HeaderBreadcrumbComponent } from './breadcrumb.component'
import { HeaderSnekDirective } from './snek.directive'
import { HeaderMobileComponent } from './mobile.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
@Component({
selector: 'header[appHeader]',
template: `
<a headerHome routerLink="/portal/dashboard" routerLinkActive="active">
<div class="plaque"></div>
</a>
@for (item of breadcrumbs$ | async; track $index) {
<a
routerLinkActive="active"
[routerLink]="item.routerLink"
[routerLinkActiveOptions]="options"
[headerBreadcrumb]="item"
>
<div class="plaque"></div>
</a>
}
<div [style.flex]="1" [headerMobile]="breadcrumbs$ | async">
<div class="plaque"></div>
<img
[appSnek]="(snekScore$ | async) || 0"
class="snek"
alt="Play Snake"
src="assets/img/icons/snek.png"
/>
</div>
<header-connection><div class="plaque"></div></header-connection>
<header-corner><div class="plaque"></div></header-corner>
`,
styles: [
`
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
height: 3.5rem;
padding: 0.375rem;
--clip-path: polygon(
0% 0%,
calc(100% - 1.75rem) 0%,
100% 100%,
1.75rem 100%
);
> * {
@include transition(all);
position: relative;
margin-left: -1.25rem;
backdrop-filter: blur(1rem);
clip-path: var(--clip-path);
&:active {
backdrop-filter: blur(2rem) brightness(0.75) saturate(0.75);
}
}
&:has([data-connection='error']) {
--status: var(--tui-error-fill);
}
&:has([data-connection='warning']) {
--status: var(--tui-warning-fill);
}
&:has([data-connection='neutral']) {
--status: var(--tui-neutral-fill);
}
&:has([data-connection='success']) {
--status: var(--tui-success-fill);
}
}
header-connection .plaque::before {
box-shadow:
inset 0 1px rgba(255, 255, 255, 0.25),
inset 0 -0.25rem var(--tui-success-fill);
}
:host-context(tui-root._mobile) {
a {
display: none;
}
header-corner .plaque::before {
box-shadow:
inset 0 1px rgb(255 255 255 / 25%),
inset -0.375rem 0 var(--status);
}
}
.plaque {
@include transition(opacity);
position: absolute;
inset: 0;
z-index: -1;
filter: url(#round-corners);
opacity: 0.5;
.active & {
opacity: 0.75;
&::before {
// TODO: Theme
background: #363636;
}
}
&::before {
@include transition(all);
content: '';
position: absolute;
inset: 0;
clip-path: var(--clip-path);
// TODO: Theme
background: #5f5f5f;
box-shadow: inset 0 1px rgb(255 255 255 / 25%);
}
}
.snek {
@include center-top();
@include transition(opacity);
right: 2rem;
width: 1rem;
opacity: 0.2;
cursor: pointer;
&:hover {
opacity: 1;
}
}
`,
],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
RouterLinkActive,
AsyncPipe,
HeaderConnectionComponent,
HeaderHomeComponent,
HeaderCornerComponent,
HeaderSnekDirective,
HeaderBreadcrumbComponent,
HeaderMobileComponent,
],
})
export class HeaderComponent {
readonly options = OPTIONS
readonly breadcrumbs$ = inject(BreadcrumbsService)
readonly snekScore$ = inject(PatchDB<DataModel>).watch$(
'ui',
'gaming',
'snake',
'highScore',
)
}
const OPTIONS: IsActiveMatchOptions = {
paths: 'exact',
queryParams: 'ignored',
fragment: 'ignored',
matrixParams: 'ignored',
}

View File

@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiIconModule } from '@taiga-ui/experimental'
@Component({
standalone: true,
selector: 'a[headerHome]',
template: `
<ng-content />
<tui-icon icon="/assets/img/icons/home.svg" [style.font-size.rem]="2" />
`,
styles: [
`
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 2.5rem 0 1rem;
margin: 0 !important;
--clip-path: polygon(
calc(100% - 1.75rem) 0%,
calc(100% - 0.875rem) 50%,
calc(100% - 1.75rem) 100%,
0% 100%,
0% 0%
);
&.active {
--clip-path: polygon(
calc(100% - 1.75rem) 0%,
calc(100% - 0.875rem) 50%,
100% 100%,
0% 100%,
0% 0%
);
}
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIconModule],
})
export class HeaderHomeComponent {}

View File

@@ -0,0 +1,229 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
TuiDataListModule,
TuiDialogOptions,
TuiDialogService,
TuiHostedDropdownModule,
TuiSvgModule,
} from '@taiga-ui/core'
import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { ABOUT } from './about.component'
import { getAllPackages } from 'src/app/utils/get-package-data'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { HeaderConnectionComponent } from './connection.component'
@Component({
selector: 'header-menu',
template: `
<tui-hosted-dropdown [content]="content" [tuiDropdownMaxHeight]="9999">
<button tuiIconButton appearance="">
<img style="max-width: 62%" src="assets/img/icon.png" alt="StartOS" />
</button>
<ng-template #content>
<tui-data-list>
<header-connection class="status">
<h3 class="title">StartOS</h3>
</header-connection>
<button tuiOption class="item" (click)="about()">
<tui-icon icon="tuiIconInfo" />
About this server
</button>
<tui-opt-group>
@for (link of links; track $index) {
<a
tuiOption
class="item"
target="_blank"
rel="noreferrer"
[href]="link.href"
>
<tui-icon [icon]="link.icon" />
{{ link.name }}
<tui-icon class="external" icon="tuiIconArrowUpRight" />
</a>
}
</tui-opt-group>
<tui-opt-group>
@for (item of system; track $index) {
<button tuiOption class="item" (click)="prompt(item.action)">
<tui-icon [icon]="item.icon" />
{{ item.action }}
</button>
}
</tui-opt-group>
<tui-opt-group>
<button tuiOption class="item" (click)="logout()">
<tui-icon icon="tuiIconLogOut" />
Logout
</button>
</tui-opt-group>
</tui-data-list>
</ng-template>
</tui-hosted-dropdown>
`,
styles: [
`
tui-icon {
font-size: 1rem;
}
.item {
justify-content: flex-start;
gap: 0.75rem;
}
.status {
display: flex !important;
font-size: 0;
padding: 0 0.5rem;
height: 2rem;
width: 14rem;
}
.title {
margin: 0 auto 0 0;
font: var(--tui-font-text-l);
font-weight: bold;
}
.external {
margin-left: auto;
padding-left: 0.5rem;
}
`,
],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiHostedDropdownModule,
TuiDataListModule,
TuiSvgModule,
TuiButtonModule,
TuiIconModule,
HeaderConnectionComponent,
],
})
export class HeaderMenuComponent {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly auth = inject(AuthService)
private readonly patch = inject(PatchDB<DataModel>)
private readonly dialogs = inject(TuiDialogService)
readonly links = [
{
name: 'User Manual',
icon: 'tuiIconBookOpen',
href: 'https://docs.start9.com/0.3.5.x/user-manual',
},
{
name: 'Contact Support',
icon: 'tuiIconHeadphones',
href: 'https://start9.com/contact',
},
{
name: 'Donate to Start9',
icon: 'tuiIconDollarSign',
href: 'https://donate.start9.com',
},
]
readonly system = [
{
icon: 'tuiIconTool',
action: 'System Rebuild',
},
{
icon: 'tuiIconRefreshCw',
action: 'Restart',
},
{
icon: 'tuiIconPower',
action: 'Shutdown',
},
] as const
about() {
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe()
}
logout() {
this.api.logout({}).catch(e => console.error('Failed to log out', e))
this.auth.setUnverified()
}
async prompt(action: keyof typeof METHODS) {
const minutes =
action === 'System Rebuild'
? Object.keys(await getAllPackages(this.patch)).length * 2
: ''
this.dialogs
.open(TUI_PROMPT, getOptions(action, minutes))
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open(`Beginning ${action}...`).subscribe()
try {
await this.api[METHODS[action]]({})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
}
const METHODS = {
Restart: 'restartServer',
Shutdown: 'shutdownServer',
'System Rebuild': 'systemRebuild',
} as const
function getOptions(
key: keyof typeof METHODS,
minutes: unknown,
): Partial<TuiDialogOptions<TuiPromptData>> {
switch (key) {
case 'Restart':
return {
label: 'Restart',
size: 's',
data: {
content:
'Are you sure you want to restart your server? It can take several minutes to come back online.',
yes: 'Restart',
no: 'Cancel',
},
}
case 'Shutdown':
return {
label: 'Warning',
size: 's',
data: {
content:
'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in',
yes: 'Shutdown',
no: 'Cancel',
},
}
default:
return {
label: 'Warning',
size: 's',
data: {
content: `This action will tear down all service containers and rebuild them from scratch. No data will be deleted. This action is useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues. It may take up to ${minutes} minutes to complete. During this time, you will lose all connectivity to your server.`,
yes: 'Rebuild',
no: 'Cancel',
},
}
}
}

View File

@@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { TuiIconModule } from '@taiga-ui/experimental'
import { Breadcrumb } from 'src/app/services/breadcrumbs.service'
@Component({
standalone: true,
selector: '[headerMobile]',
template: `
@if (headerMobile?.length) {
<a [routerLink]="back" [style.padding.rem]="0.75">
<tui-icon icon="tuiIconArrowLeft" />
</a>
}
<span class="title">{{ title }}</span>
<ng-content />
`,
styles: [
`
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
align-items: center;
font-size: 1rem;
> * {
display: none;
}
}
:host-context(tui-root._mobile) {
margin: 0;
--clip-path: polygon(
0% 0%,
calc(100% - 1.75rem) 0%,
100% 100%,
0% 100%
);
> * {
display: block;
}
}
.title {
@include text-overflow();
max-width: calc(100% - 5rem);
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIconModule, RouterLink],
})
export class HeaderMobileComponent {
@Input() headerMobile: readonly Breadcrumb[] | null = []
get title() {
return this.headerMobile?.[this.headerMobile?.length - 1]?.title || ''
}
get back() {
return (
this.headerMobile?.[this.headerMobile?.length - 2]?.routerLink ||
'/portal/dashboard'
)
}
}

View File

@@ -0,0 +1,85 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { TuiSvgModule } from '@taiga-ui/core'
import { TuiButtonModule, TuiTitleModule } from '@taiga-ui/experimental'
import { TuiLineClampModule } from '@taiga-ui/kit'
import { ServerNotification } from 'src/app/services/api/api.types'
import { NotificationService } from 'src/app/services/notification.service'
@Component({
selector: 'header-notification',
template: `
<tui-svg
style="align-self: flex-start; margin: 0.25rem 0;"
[style.color]="color"
[src]="icon"
></tui-svg>
<div tuiTitle>
<div tuiSubtitle><ng-content></ng-content></div>
<div [style.color]="color">
{{ notification.title }}
</div>
<tui-line-clamp
tuiSubtitle
style="pointer-events: none"
[linesLimit]="4"
[lineHeight]="16"
[content]="notification.message"
(overflownChange)="overflow = $event"
/>
<div style="display: flex; gap: 0.5rem; padding-top: 0.5rem;">
<button
*ngIf="notification.code === 1"
tuiButton
appearance="secondary"
size="xs"
(click)="service.viewReport(notification)"
>
View Report
</button>
<button
*ngIf="overflow"
tuiButton
appearance="secondary"
size="xs"
(click)="service.viewFull(notification)"
>
View full
</button>
<ng-content select="a"></ng-content>
</div>
</div>
<ng-content select="button"></ng-content>
`,
styles: [':host { box-shadow: 0 1px var(--tui-clear); }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
TuiSvgModule,
TuiTitleModule,
TuiButtonModule,
TuiLineClampModule,
],
})
export class HeaderNotificationComponent<T extends number> {
readonly service = inject(NotificationService)
@Input({ required: true }) notification!: ServerNotification<T>
overflow = false
get color(): string {
return this.service.getColor(this.notification)
}
get icon(): string {
return this.service.getIcon(this.notification)
}
}

View File

@@ -0,0 +1,151 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
Output,
inject,
EventEmitter,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { TuiForModule } from '@taiga-ui/cdk'
import { TuiScrollbarModule } from '@taiga-ui/core'
import {
TuiAvatarStackModule,
TuiButtonModule,
TuiCellModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import { Subject, first, tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { HeaderNotificationComponent } from './notification.component'
import { toRouterLink } from 'src/app/utils/to-router-link'
import {
ServerNotification,
ServerNotifications,
} from 'src/app/services/api/api.types'
import { NotificationService } from 'src/app/services/notification.service'
import { ToManifestPipe } from '../../pipes/to-manifest'
@Component({
selector: 'header-notifications',
template: `
<ng-container *ngIf="notifications$ | async as notifications">
<h3 class="g-title" style="padding: 0 1rem">
Notifications
<a
*ngIf="notifications.length"
style="margin-left: auto; text-transform: none; font-size: 0.9rem; font-weight: 600;"
(click)="markAllSeen(notifications[0].id)"
>
Mark All Seen
</a>
</h3>
<tui-scrollbar *ngIf="packageData$ | async as packageData">
<header-notification
*ngFor="let not of notifications; let i = index"
tuiCell
[notification]="not"
>
<ng-container *ngIf="not.packageId as pkgId">
{{
packageData[pkgId]
? (packageData[pkgId] | toManifest).title
: pkgId
}}
</ng-container>
<button
style="align-self: flex-start; flex-shrink: 0;"
tuiIconButton
appearance="icon"
iconLeft="tuiIconMinusCircle"
(click)="markSeen(notifications, not)"
></button>
<a
*ngIf="not.packageId && packageData[not.packageId]"
tuiButton
size="xs"
appearance="secondary"
[routerLink]="getLink(not.packageId || '')"
>
View Service
</a>
</header-notification>
</tui-scrollbar>
<a
style="margin: 2rem; text-align: center; font-size: 0.9rem; font-weight: 600;"
[routerLink]="'/portal/system/notifications'"
>
View All
</a>
</ng-container>
`,
styles: [
`
:host {
display: flex;
flex-direction: column;
height: 100%;
width: 22rem;
max-width: 80vw;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
RouterLink,
TuiForModule,
TuiScrollbarModule,
TuiButtonModule,
HeaderNotificationComponent,
TuiCellModule,
TuiAvatarStackModule,
TuiTitleModule,
ToManifestPipe,
],
})
export class HeaderNotificationsComponent {
private readonly patch = inject(PatchDB<DataModel>)
private readonly service = inject(NotificationService)
readonly packageData$ = this.patch.watch$('packageData').pipe(first())
readonly notifications$ = new Subject<ServerNotifications>()
@Output() onEmpty = new EventEmitter()
ngAfterViewInit() {
this.patch
.watch$('serverInfo', 'unreadNotifications', 'recent')
.pipe(
tap(recent => this.notifications$.next(recent)),
first(),
)
.subscribe()
}
markSeen(
current: ServerNotifications,
notification: ServerNotification<number>,
) {
this.notifications$.next(current.filter(c => c.id !== notification.id))
if (current.length === 1) this.onEmpty.emit()
this.service.markSeen([notification])
}
markAllSeen(latestId: number) {
this.notifications$.next([])
this.service.markSeenAll(latestId)
this.onEmpty.emit()
}
getLink(id: string) {
return toRouterLink(id)
}
}

View File

@@ -0,0 +1,298 @@
import { DOCUMENT } from '@angular/common'
import {
AfterViewInit,
Component,
HostListener,
inject,
OnDestroy,
} from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { TuiDialogContext } from '@taiga-ui/core'
@Component({
standalone: true,
template: `
<div class="canvas-center">
<canvas id="game"></canvas>
</div>
<footer class="footer">
<strong>Score: {{ score }}</strong>
<span>High Score: {{ highScore }}</span>
<button tuiButton (click)="dismiss()">Save and Quit</button>
</footer>
`,
styles: [
`
.canvas-center {
min-height: 50vh;
padding-top: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 32px;
}
`,
],
imports: [TuiButtonModule],
})
export class HeaderSnekComponent implements AfterViewInit, OnDestroy {
private readonly document = inject(DOCUMENT)
private readonly dialog =
inject<TuiDialogContext<number, number>>(POLYMORPHEUS_CONTEXT)
highScore: number = this.dialog.data
score = 0
private readonly speed = 45
private readonly width = 40
private readonly height = 26
private grid = NaN
private readonly startingLength = 4
private xDown?: number
private yDown?: number
private canvas!: HTMLCanvasElement
private image!: HTMLImageElement
private context!: CanvasRenderingContext2D
private snake: any
private bitcoin: { x: number; y: number } = { x: NaN, y: NaN }
private moveQueue: String[] = []
private destroyed = false
dismiss() {
this.dialog.completeWith(this.highScore)
}
@HostListener('document:keydown', ['$event'])
keyEvent(e: KeyboardEvent) {
this.moveQueue.push(e.key)
}
@HostListener('touchstart', ['$event'])
touchStart(e: TouchEvent) {
this.handleTouchStart(e)
}
@HostListener('touchmove', ['$event'])
touchMove(e: TouchEvent) {
this.handleTouchMove(e)
}
@HostListener('window:resize')
sizeChange() {
this.init()
}
ngOnDestroy() {
this.destroyed = true
}
ngAfterViewInit() {
this.init()
this.image = new Image()
this.image.onload = () => {
requestAnimationFrame(async () => await this.loop())
}
this.image.src = '../../../../../../assets/img/icons/bitcoin.svg'
}
init() {
this.canvas = this.document.querySelector('canvas#game')!
this.canvas.style.border = '1px solid #e0e0e0'
this.context = this.canvas.getContext('2d')!
const container = this.document.querySelector('.canvas-center')!
this.grid = Math.min(
Math.floor(container.clientWidth / this.width),
Math.floor(container.clientHeight / this.height),
)
this.snake = {
x: this.grid * (Math.floor(this.width / 2) - this.startingLength),
y: this.grid * Math.floor(this.height / 2),
// snake velocity. moves one grid length every frame in either the x or y direction
dx: this.grid,
dy: 0,
// keep track of all grids the snake body occupies
cells: [],
// length of the snake. grows when eating an bitcoin
maxCells: this.startingLength,
}
this.bitcoin = {
x: this.getRandomInt(0, this.width) * this.grid,
y: this.getRandomInt(0, this.height) * this.grid,
}
this.canvas.width = this.grid * this.width
this.canvas.height = this.grid * this.height
this.context.imageSmoothingEnabled = false
}
getTouches(evt: TouchEvent) {
return evt.touches
}
handleTouchStart(evt: TouchEvent) {
const firstTouch = this.getTouches(evt)[0]
this.xDown = firstTouch.clientX
this.yDown = firstTouch.clientY
}
handleTouchMove(evt: TouchEvent) {
if (!this.xDown || !this.yDown) {
return
}
var xUp = evt.touches[0].clientX
var yUp = evt.touches[0].clientY
var xDiff = this.xDown - xUp
var yDiff = this.yDown - yUp
if (Math.abs(xDiff) > Math.abs(yDiff)) {
/*most significant*/
if (xDiff > 0) {
this.moveQueue.push('ArrowLeft')
} else {
this.moveQueue.push('ArrowRight')
}
} else {
if (yDiff > 0) {
this.moveQueue.push('ArrowUp')
} else {
this.moveQueue.push('ArrowDown')
}
}
/* reset values */
this.xDown = undefined
this.yDown = undefined
}
// game loop
async loop() {
if (this.destroyed) return
await pauseFor(this.speed)
requestAnimationFrame(async () => await this.loop())
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
// move snake by its velocity
this.snake.x += this.snake.dx
this.snake.y += this.snake.dy
if (this.moveQueue.length) {
const move = this.moveQueue.shift()
// left arrow key
if (move === 'ArrowLeft' && this.snake.dx === 0) {
this.snake.dx = -this.grid
this.snake.dy = 0
}
// up arrow key
else if (move === 'ArrowUp' && this.snake.dy === 0) {
this.snake.dy = -this.grid
this.snake.dx = 0
}
// right arrow key
else if (move === 'ArrowRight' && this.snake.dx === 0) {
this.snake.dx = this.grid
this.snake.dy = 0
}
// down arrow key
else if (move === 'ArrowDown' && this.snake.dy === 0) {
this.snake.dy = this.grid
this.snake.dx = 0
}
}
// edge death
if (
this.snake.x < 0 ||
this.snake.y < 0 ||
this.snake.x >= this.canvas.width ||
this.snake.y >= this.canvas.height
) {
this.death()
}
// keep track of where snake has been. front of the array is always the head
this.snake.cells.unshift({ x: this.snake.x, y: this.snake.y })
// remove cells as we move away from them
if (this.snake.cells.length > this.snake.maxCells) {
this.snake.cells.pop()
}
// draw bitcoin
this.context.fillStyle = '#ff4961'
this.context.drawImage(
this.image,
this.bitcoin.x - 1,
this.bitcoin.y - 1,
this.grid + 2,
this.grid + 2,
)
// draw snake one cell at a time
this.context.fillStyle = '#2fdf75'
const firstCell = this.snake.cells[0]
for (let index = 0; index < this.snake.cells.length; index++) {
const cell = this.snake.cells[index]
// drawing 1 px smaller than the grid creates a grid effect in the snake body so you can see how long it is
this.context.fillRect(cell.x, cell.y, this.grid - 1, this.grid - 1)
// snake ate bitcoin
if (cell.x === this.bitcoin.x && cell.y === this.bitcoin.y) {
this.score++
this.highScore = Math.max(this.score, this.highScore)
this.snake.maxCells++
this.bitcoin.x = this.getRandomInt(0, this.width) * this.grid
this.bitcoin.y = this.getRandomInt(0, this.height) * this.grid
}
if (index > 0) {
// check collision with all cells after this one (modified bubble sort)
// snake occupies same space as a body part. reset game
if (
firstCell.x === this.snake.cells[index].x &&
firstCell.y === this.snake.cells[index].y
) {
this.death()
}
}
}
}
death() {
this.snake.x =
this.grid * (Math.floor(this.width / 2) - this.startingLength)
this.snake.y = this.grid * Math.floor(this.height / 2)
this.snake.cells = []
this.snake.maxCells = this.startingLength
this.snake.dx = this.grid
this.snake.dy = 0
this.bitcoin.x = this.getRandomInt(0, 25) * this.grid
this.bitcoin.y = this.getRandomInt(0, 25) * this.grid
this.score = 0
}
getRandomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min)) + min
}
}

View File

@@ -0,0 +1,47 @@
import { Directive, HostListener, inject, Input } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { HeaderSnekComponent } from './snek.component'
@Directive({
standalone: true,
selector: 'img[appSnek]',
})
export class HeaderSnekDirective {
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
@Input()
appSnek = 0
@HostListener('click')
async onClick() {
this.dialogs
.open<number>(new PolymorpheusComponent(HeaderSnekComponent), {
label: 'Snake!',
closeable: false,
dismissible: false,
data: this.appSnek,
})
.pipe(filter(score => score > this.appSnek))
.subscribe(async score => {
const loader = this.loader.open('Saving high score...').subscribe()
try {
await this.api.setDbValue<number>(
['gaming', 'snake', 'high-score'],
score,
)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
}

View File

@@ -0,0 +1,55 @@
import {
ChangeDetectionStrategy,
Component,
Input,
inject,
} from '@angular/core'
import { AddressItemComponent } from './address-item.component'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { AddressDetails, AddressesService } from './interface.utils'
@Component({
standalone: true,
selector: 'app-address-group',
template: `
<div>
@if (addresses.length) {
<button
class="icon-add-btn"
tuiIconButton
appearance="secondary"
iconLeft="tuiIconPlus"
(click)="service.add()"
>
Add
</button>
}
<ng-content></ng-content>
</div>
@for (address of addresses; track $index) {
<app-address-item [label]="address.label" [address]="address.url" />
} @empty {
<button
tuiButton
iconLeft="tuiIconPlus"
[style.align-self]="'flex-start'"
(click)="service.add()"
>
Add Address
</button>
}
`,
imports: [AddressItemComponent, TuiButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: `
.icon-add-btn {
float: right;
margin-left: 2rem;
}
`,
})
export class AddressGroupComponent {
readonly service = inject(AddressesService)
@Input({ required: true }) addresses!: AddressDetails[]
}

View File

@@ -0,0 +1,107 @@
import { NgIf } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { CopyService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import {
TuiBadgeModule,
TuiButtonModule,
TuiCellModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { mask } from 'src/app/utils/mask'
import { InterfaceComponent } from './interface.component'
import { AddressesService } from './interface.utils'
@Component({
standalone: true,
selector: 'app-address-item',
template: `
<div tuiCell>
<tui-badge appearance="success">
{{ label }}
</tui-badge>
<h3 tuiTitle>
<span tuiSubtitle>
{{ interface.serviceInterface.masked ? mask : address }}
</span>
</h3>
<button
*ngIf="interface.serviceInterface.type === 'ui'"
tuiIconButton
iconLeft="tuiIconExternalLink"
appearance="icon"
(click)="launch(address)"
>
Launch
</button>
<button
tuiIconButton
iconLeft="tuiIconGrid"
appearance="icon"
(click)="showQR(address)"
>
Show QR code
</button>
<button
tuiIconButton
iconLeft="tuiIconCopy"
appearance="icon"
(click)="copyService.copy(address)"
>
Copy URL
</button>
<button
tuiIconButton
iconLeft="tuiIconTrash"
appearance="icon"
(click)="service.remove()"
>
Destroy
</button>
</div>
`,
imports: [
NgIf,
TuiCellModule,
TuiTitleModule,
TuiButtonModule,
TuiBadgeModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressItemComponent {
private readonly window = inject(WINDOW)
private readonly dialogs = inject(TuiDialogService)
readonly service = inject(AddressesService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceComponent)
@Input() label?: string
@Input({ required: true }) address!: string
get mask(): string {
return mask(this.address, 64)
}
launch(url: string): void {
this.window.open(url, '_blank', 'noreferrer')
}
showQR(data: string) {
this.dialogs
.open(new PolymorpheusComponent(QRModal), {
size: 'auto',
data,
})
.subscribe()
}
}

View File

@@ -0,0 +1,79 @@
import { Directive, Input } from '@angular/core'
import { AddressesService } from '../interface.utils'
import { inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions } from '@taiga-ui/core'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { getClearnetSpec } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
import { InterfaceComponent } from '../interface.component'
type ClearnetForm = {
domain: string
subdomain: string | null
}
@Directive({
standalone: true,
selector: '[clearnetAddresses]',
providers: [
{ provide: AddressesService, useExisting: ClearnetAddressesDirective },
],
})
export class ClearnetAddressesDirective implements AddressesService {
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
@Input({ required: true }) network!: NetworkInfo
async add() {
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
label: 'Select Domain/Subdomain',
data: {
spec: await getClearnetSpec(this.network),
buttons: [
{
text: 'Manage domains',
link: 'portal/system/settings/domains',
},
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
}
this.formDialog.open(FormComponent, options)
}
async remove() {}
private async save(domainInfo: ClearnetForm): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
if (this.interface.packageContext) {
await this.api.setInterfaceClearnetAddress({
...this.interface.packageContext,
domainInfo,
})
} else {
await this.api.setServerClearnetAddress({ domainInfo })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,14 @@
import { Directive } from '@angular/core'
import { AddressesService } from '../interface.utils'
@Directive({
standalone: true,
selector: '[localAddresses]',
providers: [
{ provide: AddressesService, useExisting: LocalAddressesDirective },
],
})
export class LocalAddressesDirective implements AddressesService {
async add() {}
async remove() {}
}

View File

@@ -0,0 +1,14 @@
import { Directive } from '@angular/core'
import { AddressesService } from '../interface.utils'
@Directive({
standalone: true,
selector: '[torAddresses]',
providers: [
{ provide: AddressesService, useExisting: TorAddressesDirective },
],
})
export class TorAddressesDirective implements AddressesService {
async add() {}
async remove() {}
}

View File

@@ -0,0 +1,112 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiCardModule, TuiSurfaceModule } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import { AddressGroupComponent } from 'src/app/routes/portal/components/interfaces/address-group.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { AddressDetails } from './interface.utils'
import { ClearnetAddressesDirective } from './directives/clearnet.directive'
import { LocalAddressesDirective } from './directives/local.directive'
import { TorAddressesDirective } from './directives/tor.directive'
@Component({
standalone: true,
selector: 'app-interface',
template: `
<h3 class="g-title">Clearnet</h3>
<app-address-group
*ngIf="network$ | async as network"
clearnetAddresses
tuiCardLarge="compact"
tuiSurface="elevated"
[network]="network"
[addresses]="serviceInterface.addresses.clearnet"
>
<em>
Add a clearnet address to expose this interface on the Internet.
Clearnet addresses are fully public and not anonymous.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#clearnet"
target="_blank"
rel="noreferrer"
>
<strong>Learn More</strong>
</a>
</em>
</app-address-group>
<h3 class="g-title">Tor</h3>
<app-address-group
torAddresses
tuiCardLarge="compact"
tuiSurface="elevated"
[addresses]="serviceInterface.addresses.tor"
>
<em>
Add an onion address to anonymously expose this interface on the
darknet. Onion addresses can only be reached over the Tor network.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#tor"
target="_blank"
rel="noreferrer"
>
<strong>Learn More</strong>
</a>
</em>
</app-address-group>
<h3 class="g-title">Local</h3>
<app-address-group
localAddresses
tuiCardLarge="compact"
tuiSurface="elevated"
[addresses]="serviceInterface.addresses.local"
>
<em>
Add a local address to expose this interface on your Local Area Network
(LAN). Local addresses can only be accessed by devices connected to the
same LAN as your server, either directly or using a VPN.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#local"
target="_blank"
rel="noreferrer"
>
<strong>Learn More</strong>
</a>
</em>
</app-address-group>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
AddressGroupComponent,
TuiCardModule,
TuiSurfaceModule,
ClearnetAddressesDirective,
TorAddressesDirective,
LocalAddressesDirective,
],
})
export class InterfaceComponent {
readonly network$ = inject(PatchDB<DataModel>).watch$('serverInfo', 'network')
@Input() packageContext?: {
packageId: string
interfaceId: string
}
@Input({ required: true }) serviceInterface!: ServiceInterfaceWithAddresses
}
export type ServiceInterfaceWithAddresses = T.ServiceInterface & {
addresses: {
clearnet: AddressDetails[]
local: AddressDetails[]
tor: AddressDetails[]
}
}

View File

@@ -0,0 +1,142 @@
import { CB, CT, T } from '@start9labs/start-sdk'
import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiPromptData } from '@taiga-ui/kit'
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
export abstract class AddressesService {
abstract add(): Promise<void>
abstract remove(): Promise<void>
}
export const REMOVE: Partial<TuiDialogOptions<TuiPromptData>> = {
label: 'Confirm',
size: 's',
data: {
content: 'Remove clearnet address?',
yes: 'Remove',
no: 'Cancel',
},
}
export function getClearnetSpec({
domains,
start9ToSubdomain,
}: NetworkInfo): Promise<CT.InputSpec> {
const start9ToDomain = `${start9ToSubdomain?.value}.start9.to`
const base = start9ToSubdomain ? { [start9ToDomain]: start9ToDomain } : {}
const values = domains.reduce((prev, curr) => {
return {
[curr.value]: curr.value,
...prev,
}
}, base)
return configBuilderToSpec(
CB.Config.of({
domain: CB.Value.select({
name: 'Domain',
required: { default: null },
values,
}),
subdomain: CB.Value.text({
name: 'Subdomain',
required: false,
}),
}),
)
}
export type AddressDetails = {
label?: string
url: string
}
export function getAddresses(
serviceInterface: T.ServiceInterfaceWithHostInfo,
): {
clearnet: AddressDetails[]
local: AddressDetails[]
tor: AddressDetails[]
} {
const host = serviceInterface.hostInfo
const addressInfo = serviceInterface.addressInfo
const username = addressInfo.username ? addressInfo.username + '@' : ''
const suffix = addressInfo.suffix || ''
const hostnames =
host.kind === 'multi'
? host.hostnames
: host.hostname
? [host.hostname]
: []
const clearnet: AddressDetails[] = []
const local: AddressDetails[] = []
const tor: AddressDetails[] = []
hostnames.forEach(h => {
let scheme = ''
let port = ''
if (h.hostname.sslPort) {
port = h.hostname.sslPort === 443 ? '' : `:${h.hostname.sslPort}`
scheme = addressInfo.bindOptions.addSsl?.scheme
? `${addressInfo.bindOptions.addSsl.scheme}://`
: ''
}
if (h.hostname.port) {
port = h.hostname.port === 80 ? '' : `:${h.hostname.port}`
scheme = addressInfo.bindOptions.scheme
? `${addressInfo.bindOptions.scheme}://`
: ''
}
if (h.kind === 'onion') {
tor.push({
label: h.hostname.sslPort ? 'HTTPS' : 'HTTP',
url: toHref(scheme, username, h.hostname.value, port, suffix),
})
} else {
const hostnameKind = h.hostname.kind
if (hostnameKind === 'domain') {
tor.push({
url: toHref(
scheme,
username,
`${h.hostname.subdomain}.${h.hostname.domain}`,
port,
suffix,
),
})
} else {
local.push({
label:
hostnameKind === 'local'
? 'Local'
: `${h.networkInterfaceId} (${hostnameKind})`,
url: toHref(scheme, username, h.hostname.value, port, suffix),
})
}
}
})
return {
clearnet,
local,
tor,
}
}
function toHref(
scheme: string,
username: string,
hostname: string,
port: string,
suffix: string,
): string {
return `${scheme}${username}${hostname}${port}${suffix}`
}

View File

@@ -0,0 +1,52 @@
import { Directive, HostListener, inject, Input } from '@angular/core'
import {
convertAnsi,
DownloadHTMLService,
ErrorService,
FetchLogsReq,
FetchLogsRes,
LoadingService,
} from '@start9labs/shared'
import { LogsComponent } from './logs.component'
@Directive({
standalone: true,
selector: 'button[logsDownload]',
})
export class LogsDownloadDirective {
private readonly component = inject(LogsComponent)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly downloadHtml = inject(DownloadHTMLService)
@Input({ required: true })
logsDownload!: (params: FetchLogsReq) => Promise<FetchLogsRes>
@HostListener('click')
async download() {
const loader = this.loader.open('Processing 10,000 logs...').subscribe()
try {
const { entries } = await this.logsDownload({
before: true,
limit: 10000,
})
this.downloadHtml.download(
`${this.component.context}-logs.html`,
convertAnsi(entries),
STYLES,
)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
const STYLES = {
'background-color': '#222428',
color: '#e0e0e0',
'font-family': 'monospace',
}

View File

@@ -0,0 +1,36 @@
import { Directive, inject, Output } from '@angular/core'
import { IntersectionObserveeService } from '@ng-web-apis/intersection-observer'
import { convertAnsi, ErrorService } from '@start9labs/shared'
import { catchError, defer, filter, from, map, of, switchMap, tap } from 'rxjs'
import { LogsComponent } from './logs.component'
@Directive({
standalone: true,
selector: '[logsFetch]',
})
export class LogsFetchDirective {
private readonly observer = inject(IntersectionObserveeService)
private readonly component = inject(LogsComponent)
private readonly errors = inject(ErrorService)
@Output()
readonly logsFetch = defer(() => this.observer).pipe(
filter(([{ isIntersecting }]) => isIntersecting && !this.component.scroll),
switchMap(() =>
from(
this.component.fetchLogs({
cursor: this.component.startCursor,
before: true,
limit: 400,
}),
),
),
tap(res => this.component.setCursor(res.startCursor)),
map(({ entries }) => convertAnsi(entries)),
catchError(e => {
this.errors.handleError(e)
return of('')
}),
)
}

View File

@@ -0,0 +1,66 @@
<tui-scrollbar class="scrollbar">
<section
class="top"
waIntersectionObserver
(waIntersectionObservee)="onLoading($event[0].isIntersecting)"
(logsFetch)="onPrevious($event)"
>
@if (loading) {
<tui-loader textContent="Loading older logs" />
}
</section>
<section #el childList (waMutationObserver)="restoreScroll(el)">
@for (log of previous; track log) {
<pre [innerHTML]="log | dompurify"></pre>
}
</section>
@if (followLogs | logs | async; as logs) {
<section childList (waMutationObserver)="scrollToBottom()">
@for (log of logs; track log) {
<pre [innerHTML]="log | dompurify"></pre>
}
@if ((status$ | async) !== 'connected') {
<p class="loading-dots" [attr.data-status]="status$.value">
{{
status$.value === 'reconnecting'
? 'Reconnecting'
: 'Waiting for network connectivity'
}}
</p>
}
</section>
} @else {
<tui-loader textContent="Loading logs" [style.margin-top.rem]="5" />
}
<section
#bottom
class="bottom"
waIntersectionObserver
(waIntersectionObservee)="
setScroll($event[$event.length - 1].isIntersecting)
"
></section>
</tui-scrollbar>
<footer class="footer">
<button
tuiButton
appearance="flat"
iconLeft="tuiIconArrowDownCircle"
(click)="setScroll(true); scrollToBottom()"
>
Scroll to bottom
</button>
<button
tuiButton
appearance="flat"
iconLeft="tuiIconDownload"
[logsDownload]="fetchLogs"
>
Download
</button>
</footer>

View File

@@ -0,0 +1,43 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.scrollbar {
flex: 1;
}
.loading-dots {
text-align: center;
}
.top {
height: 10rem;
margin-bottom: -5rem;
}
.bottom {
height: 3rem;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 1rem;
border-top: 1px solid var(--tui-clear);
}
[data-status='reconnecting'] {
color: var(--tui-success-fill);
}
[data-status='disconnected'] {
color: var(--tui-warning-fill);
}
pre {
overflow: visible;
}

View File

@@ -0,0 +1,104 @@
import { CommonModule } from '@angular/common'
import { Component, ElementRef, Input, ViewChild } from '@angular/core'
import {
INTERSECTION_ROOT,
IntersectionObserverModule,
} from '@ng-web-apis/intersection-observer'
import { MutationObserverModule } from '@ng-web-apis/mutation-observer'
import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
import {
TuiLoaderModule,
TuiScrollbarComponent,
TuiScrollbarModule,
} from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
import { RR } from 'src/app/services/api/api.types'
import { LogsDownloadDirective } from './logs-download.directive'
import { LogsFetchDirective } from './logs-fetch.directive'
import { LogsPipe } from './logs.pipe'
import { BehaviorSubject } from 'rxjs'
@Component({
standalone: true,
selector: 'logs',
templateUrl: './logs.component.html',
styleUrls: ['./logs.component.scss'],
imports: [
CommonModule,
IntersectionObserverModule,
MutationObserverModule,
NgDompurifyModule,
TuiButtonModule,
TuiLoaderModule,
TuiScrollbarModule,
LogsDownloadDirective,
LogsFetchDirective,
LogsPipe,
],
providers: [
{
provide: INTERSECTION_ROOT,
useExisting: ElementRef,
},
],
})
export class LogsComponent {
@ViewChild('bottom')
private readonly bottom?: ElementRef<HTMLElement>
@ViewChild(TuiScrollbarComponent, { read: ElementRef })
private readonly scrollbar?: ElementRef<HTMLElement>
@Input({ required: true }) followLogs!: (
params: RR.FollowServerLogsReq,
) => Promise<RR.FollowServerLogsRes>
@Input({ required: true }) fetchLogs!: (
params: FetchLogsReq,
) => Promise<FetchLogsRes>
@Input({ required: true }) context!: string
scrollTop = 0
startCursor?: string
scroll = true
loading = false
previous: readonly string[] = []
readonly status$ = new BehaviorSubject<
'connected' | 'disconnected' | 'reconnecting'
>('connected')
onLoading(loading: boolean) {
this.loading = loading && !this.scroll
}
onPrevious(previous: string) {
this.onLoading(false)
this.scrollTop = this.scrollbar?.nativeElement.scrollTop || 0
this.previous = [previous, ...this.previous]
}
setCursor(startCursor = this.startCursor) {
this.startCursor = startCursor
}
setScroll(scroll: boolean) {
this.scroll = scroll
}
restoreScroll({ firstElementChild }: HTMLElement) {
this.scrollbar?.nativeElement.scrollTo(
this.scrollbar?.nativeElement.scrollLeft || 0,
this.scrollTop + (firstElementChild?.clientHeight || 0),
)
}
scrollToBottom() {
if (this.scroll)
this.bottom?.nativeElement.scrollIntoView({
behavior: 'smooth',
})
}
}

View File

@@ -0,0 +1,87 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { convertAnsi, toLocalIsoString } from '@start9labs/shared'
import {
bufferTime,
catchError,
defer,
filter,
ignoreElements,
map,
merge,
Observable,
repeat,
scan,
skipWhile,
startWith,
switchMap,
take,
tap,
} from 'rxjs'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { LogsComponent } from './logs.component'
@Pipe({
name: 'logs',
standalone: true,
})
export class LogsPipe implements PipeTransform {
private readonly api = inject(ApiService)
private readonly logs = inject(LogsComponent)
private readonly connection = inject(ConnectionService)
transform(
followLogs: (
params: RR.FollowServerLogsReq,
) => Promise<RR.FollowServerLogsRes>,
): Observable<readonly string[]> {
return merge(
this.logs.status$.pipe(
skipWhile(value => value === 'connected'),
filter(value => value === 'connected'),
map(() => getMessage(true)),
),
defer(() => followLogs(this.options)).pipe(
tap(r => this.logs.setCursor(r.startCursor)),
switchMap(r => this.api.openLogsWebsocket$(this.toConfig(r.guid))),
bufferTime(1000),
filter(logs => !!logs.length),
map(convertAnsi),
),
).pipe(
catchError(() =>
this.connection.connected$.pipe(
tap(v => this.logs.status$.next(v ? 'reconnecting' : 'disconnected')),
filter(Boolean),
take(1),
ignoreElements(),
startWith(getMessage(false)),
),
),
repeat(),
scan((logs: string[], log) => [...logs, log], []),
)
}
private get options() {
return this.logs.status$.value === 'connected' ? { limit: 400 } : {}
}
private toConfig(guid: string) {
return {
url: `/rpc/${guid}`,
openObserver: {
next: () => this.logs.status$.next('connected'),
},
}
}
}
function getMessage(success: boolean): string {
return `<p style="color: ${
success ? 'var(--tui-success-fill)' : 'var(--tui-error-fill)'
}; text-align: center;">${
success ? 'Reconnected' : 'Disconnected'
} at ${toLocalIsoString(new Date())}</p>`
}

View File

@@ -0,0 +1,84 @@
import { AsyncPipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { RouterLink, RouterLinkActive } from '@angular/router'
import { TuiTabBarModule } from '@taiga-ui/addon-mobile'
import { combineLatest, map, startWith } from 'rxjs'
import { SYSTEM_UTILITIES } from 'src/app/utils/system-utilities'
import { BadgeService } from 'src/app/services/badge.service'
import { NotificationService } from 'src/app/services/notification.service'
@Component({
standalone: true,
selector: 'app-tabs',
template: `
<nav tuiTabBar>
<a
tuiTabBarItem
icon="tuiIconGrid"
routerLink="/portal/dashboard"
routerLinkActive
[routerLinkActiveOptions]="{ exact: true }"
>
Services
</a>
<a
tuiTabBarItem
icon="tuiIconActivity"
routerLink="/portal/dashboard"
routerLinkActive
[routerLinkActiveOptions]="{ exact: true }"
[queryParams]="{ tab: 'metrics' }"
>
Metrics
</a>
<a
tuiTabBarItem
icon="tuiIconSettings"
routerLink="/portal/dashboard"
routerLinkActive
[routerLinkActiveOptions]="{ exact: true }"
[queryParams]="{ tab: 'utilities' }"
[badge]="(utils$ | async) || 0"
>
Utilities
</a>
<a
tuiTabBarItem
routerLinkActive
routerLink="/portal/system/notifications"
icon="tuiIconBell"
[badge]="(notification$ | async) || 0"
>
Notifications
</a>
</nav>
`,
styles: `
:host {
display: none;
// TODO: Theme
--tui-elevation-01: #333;
--tui-base-04: var(--tui-clear);
backdrop-filter: blur(1rem);
}
[tuiTabBar]::before {
opacity: 0.7;
}
:host-context(tui-root._mobile) {
display: block;
}
`,
imports: [AsyncPipe, RouterLink, RouterLinkActive, TuiTabBarModule],
})
export class TabsComponent {
private readonly badge = inject(BadgeService)
readonly utils$ = combineLatest(
Object.keys(SYSTEM_UTILITIES)
.filter(key => key !== '/portal/system/notifications')
.map(key => this.badge.getCount(key).pipe(startWith(0))),
).pipe(map(values => values.reduce((acc, value) => acc + value, 0)))
readonly notification$ = inject(NotificationService).unreadCount$
}

View File

@@ -0,0 +1,104 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
} from '@angular/core'
import { compare, getValueByPointer, Operation } from 'fast-json-patch'
import { isObject } from '@start9labs/shared'
import { tuiIsNumber } from '@taiga-ui/cdk'
import { CommonModule } from '@angular/common'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'config-dep',
template: `
<tui-notification>
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
{{ package }}
</h3>
The following modifications have been made to {{ package }} to satisfy
{{ dep }}:
<ul>
<li *ngFor="let d of diff" [innerHTML]="d"></li>
</ul>
To accept these modifications, click "Save".
</tui-notification>
`,
standalone: true,
imports: [CommonModule, TuiNotificationModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConfigDepComponent implements OnChanges {
@Input()
package = ''
@Input()
dep = ''
@Input()
original: object = {}
@Input()
value: object = {}
diff: string[] = []
ngOnChanges() {
this.diff = compare(this.original, this.value).map(
op => `${this.getPath(op)}: ${this.getMessage(op)}`,
)
}
private getPath(operation: Operation): string {
const path = operation.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (tuiIsNumber(path[path.length - 1])) {
path.pop()
}
return path.join(' &rarr; ')
}
private getMessage(operation: Operation): string {
switch (operation.op) {
case 'add':
return `Added ${this.getNewValue(operation.value)}`
case 'remove':
return `Removed ${this.getOldValue(operation.path)}`
case 'replace':
return `Changed from ${this.getOldValue(
operation.path,
)} to ${this.getNewValue(operation.value)}`
default:
return `Unknown operation`
}
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
}

View File

@@ -0,0 +1,280 @@
import { CommonModule } from '@angular/common'
import { Component, Inject, ViewChild } from '@angular/core'
import {
ErrorService,
getErrorMessage,
isEmptyObject,
LoadingService,
} from '@start9labs/shared'
import { CT } from '@start9labs/start-sdk'
import { TuiButtonModule } from '@taiga-ui/experimental'
import {
TuiDialogContext,
TuiDialogService,
TuiLoaderModule,
TuiModeModule,
TuiNotificationModule,
} from '@taiga-ui/core'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { compare, Operation } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { endWith, firstValueFrom, Subscription } from 'rxjs'
import { ConfigDepComponent } from 'src/app/routes/portal/modals/config-dep.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { hasCurrentDeps } from 'src/app/utils/has-deps'
import {
getAllPackages,
getManifest,
getPackage,
} from 'src/app/utils/get-package-data'
import { Breakages } from 'src/app/services/api/api.types'
import { InvalidService } from 'src/app/routes/portal/components/form/invalid.service'
import {
ActionButton,
FormComponent,
} from 'src/app/routes/portal/components/form.component'
import { DependentInfo } from 'src/app/types/dependent-info'
import { ToManifestPipe } from '../pipes/to-manifest'
export interface PackageConfigData {
readonly pkgId: string
readonly dependentInfo?: DependentInfo
}
@Component({
template: `
<tui-loader *ngIf="loadingText" size="l" [textContent]="loadingText" />
<tui-notification
*ngIf="!loadingText && (loadingError || !pkg)"
status="error"
>
<div [innerHTML]="loadingError"></div>
</tui-notification>
<ng-container
*ngIf="
!loadingText && !loadingError && pkg && (pkg | toManifest) as manifest
"
>
<tui-notification *ngIf="success" status="success">
{{ manifest.title }} has been automatically configured with recommended
defaults. Make whatever changes you want, then click "Save".
</tui-notification>
<config-dep
*ngIf="dependentInfo && value && original"
[package]="manifest.title"
[dep]="dependentInfo.title"
[original]="original"
[value]="value"
/>
<tui-notification *ngIf="!manifest.hasConfig" status="warning">
No config options for {{ manifest.title }} {{ manifest.version }}.
</tui-notification>
<app-form
tuiMode="onDark"
[spec]="spec"
[value]="value || {}"
[buttons]="buttons"
[patch]="patch"
>
<button
tuiButton
appearance="flat"
type="reset"
[style.margin-right]="'auto'"
>
Reset Defaults
</button>
</app-form>
</ng-container>
`,
styles: [
`
tui-notification {
font-size: 1rem;
margin-bottom: 1rem;
}
`,
],
standalone: true,
imports: [
CommonModule,
FormComponent,
TuiLoaderModule,
TuiNotificationModule,
TuiButtonModule,
TuiModeModule,
ConfigDepComponent,
ToManifestPipe,
],
providers: [InvalidService],
})
export class ConfigModal {
@ViewChild(FormComponent)
private readonly form?: FormComponent<Record<string, any>>
readonly pkgId = this.context.data.pkgId
readonly dependentInfo = this.context.data.dependentInfo
loadingError = ''
loadingText = this.dependentInfo
? `Setting properties to accommodate ${this.dependentInfo.title}`
: 'Loading Config'
pkg?: PackageDataEntry
spec: CT.InputSpec = {}
patch: Operation[] = []
buttons: ActionButton<any>[] = [
{
text: 'Save',
handler: value => this.save(value),
},
]
original: object | null = null
value: object | null = null
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<void, PackageConfigData>,
private readonly dialogs: TuiDialogService,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly embassyApi: ApiService,
private readonly patchDb: PatchDB<DataModel>,
) {}
get success(): boolean {
return (
!!this.form &&
!this.form.form.dirty &&
!this.original &&
!this.pkg?.status?.configured
)
}
async ngOnInit() {
try {
this.pkg = await getPackage(this.patchDb, this.pkgId)
if (!this.pkg) {
this.loadingError = 'This service does not exist'
return
}
if (this.dependentInfo) {
const depConfig = await this.embassyApi.dryConfigureDependency({
dependencyId: this.pkgId,
dependentId: this.dependentInfo.id,
})
this.original = depConfig.oldConfig
this.value = depConfig.newConfig || this.original
this.spec = depConfig.spec
this.patch = compare(this.original, this.value)
} else {
const { config, spec } = await this.embassyApi.getPackageConfig({
id: this.pkgId,
})
this.original = config
this.value = config
this.spec = spec
}
} catch (e: any) {
this.loadingError = getErrorMessage(e)
} finally {
this.loadingText = ''
}
}
private async save(config: any) {
const loader = new Subscription()
try {
await this.uploadFiles(config, loader)
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patchDb))) {
await this.configureDeps(config, loader)
} else {
await this.configure(config, loader)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async uploadFiles(config: Record<string, any>, loader: Subscription) {
loader.unsubscribe()
loader.closed = false
// TODO: Could be nested files
const keys = Object.keys(config).filter(key => config[key] instanceof File)
const message = `Uploading File${keys.length > 1 ? 's' : ''}...`
if (!keys.length) return
loader.add(this.loader.open(message).subscribe())
const hashes = await Promise.all(
keys.map(key => this.embassyApi.uploadFile(config[key])),
)
keys.forEach((key, i) => (config[key] = hashes[i]))
}
private async configureDeps(
config: Record<string, any>,
loader: Subscription,
) {
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Checking dependent services...').subscribe())
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config,
})
loader.unsubscribe()
loader.closed = false
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
await this.configure(config, loader)
}
}
private async configure(config: Record<string, any>, loader: Subscription) {
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Saving...').subscribe())
await this.embassyApi.setPackageConfig({ id: this.pkgId, config })
this.context.$implicit.complete()
}
private async approveBreakages(breakages: Breakages): Promise<boolean> {
const packages = await getAllPackages(this.patchDb)
const message =
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
const content = `${message}${Object.keys(breakages).map(
id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
)}</ul>`
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
return firstValueFrom(
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
)
}
}

View File

@@ -0,0 +1,123 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
import { TuiDialogContext, TuiTextfieldControllerModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiInputModule } from '@taiga-ui/kit'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
@Component({
standalone: true,
template: `
<p>{{ options.message }}</p>
<p *ngIf="options.warning" class="warning">{{ options.warning }}</p>
<form (ngSubmit)="submit(value.trim())">
<tui-input
tuiAutoFocus
[tuiTextfieldLabelOutside]="!options.label"
[tuiTextfieldCustomContent]="options.useMask ? toggle : ''"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="value"
>
{{ options.label }}
<span *ngIf="options.required !== false && options.label">*</span>
<input
tuiTextfield
[class.masked]="options.useMask && masked && value"
[placeholder]="options.placeholder || ''"
/>
</tui-input>
<footer class="g-buttons">
<button
tuiButton
type="button"
appearance="secondary"
(click)="cancel()"
>
Cancel
</button>
<button tuiButton [disabled]="!value && options.required !== false">
{{ options.buttonText || 'Submit' }}
</button>
</footer>
</form>
<ng-template #toggle>
<button
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
(click)="masked = !masked"
></button>
</ng-template>
`,
styles: [
`
.warning {
color: var(--tui-warning-fill);
}
.button {
pointer-events: auto;
margin-left: 0.25rem;
}
.masked {
-webkit-text-security: disc;
}
`,
],
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiButtonModule,
TuiTextfieldControllerModule,
TuiAutoFocusModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PromptModal {
masked = this.options.useMask
value = this.options.initialValue || ''
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<string, PromptOptions>,
) {}
get options(): PromptOptions {
return this.context.data
}
cancel() {
this.context.$implicit.complete()
}
submit(value: string) {
if (value || !this.options.required) {
this.context.$implicit.next(value)
}
}
}
export const PROMPT = new PolymorpheusComponent(PromptModal)
export interface PromptOptions {
message: string
label?: string
warning?: string
buttonText?: string
placeholder?: string
required?: boolean
useMask?: boolean
initialValue?: string | null
}

View File

@@ -0,0 +1,16 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiDialogContext } from '@taiga-ui/core'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { QrCodeModule } from 'ng-qrcode'
@Component({
standalone: true,
selector: 'qr',
template: '<qr-code [value]="context.data" size="400"></qr-code>',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [QrCodeModule],
})
export class QRModal {
readonly context =
inject<TuiDialogContext<void, string>>(POLYMORPHEUS_CONTEXT)
}

View File

@@ -0,0 +1,13 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
@Pipe({
name: 'toManifest',
standalone: true,
})
export class ToManifestPipe implements PipeTransform {
transform(pkg: PackageDataEntry) {
return getManifest(pkg)
}
}

View File

@@ -0,0 +1,70 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import {
ActivatedRoute,
NavigationEnd,
Router,
RouterOutlet,
} from '@angular/router'
import { TuiScrollbarModule } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { TabsComponent } from 'src/app/routes/portal/components/tabs.component'
import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { HeaderComponent } from './components/header/header.component'
@Component({
standalone: true,
template: `
<header appHeader>{{ name$ | async }}</header>
<main [attr.data-dashboard]="tab$ | async">
<tui-scrollbar [style.max-height.%]="100">
<router-outlet />
</tui-scrollbar>
</main>
<app-tabs />
`,
styles: [
`
:host {
height: 100%;
display: flex;
flex-direction: column;
// TODO: Theme
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
}
main {
flex: 1;
overflow: hidden;
margin-bottom: 0.375rem;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
RouterOutlet,
HeaderComponent,
TabsComponent,
TuiScrollbarModule,
],
})
export class PortalComponent {
private readonly breadcrumbs = inject(BreadcrumbsService)
private readonly _ = inject(Router)
.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(e => {
this.breadcrumbs.update(e.url.replace('/portal/service/', ''))
})
readonly name$ = inject(PatchDB<DataModel>).watch$('ui', 'name')
readonly tab$ = inject(ActivatedRoute).queryParams.pipe(
map(params => params['tab']),
)
}

View File

@@ -0,0 +1,35 @@
import { Routes } from '@angular/router'
import { PortalComponent } from './portal.component'
const ROUTES: Routes = [
{
path: '',
component: PortalComponent,
children: [
{
redirectTo: 'dashboard',
pathMatch: 'full',
path: '',
},
{
path: 'dashboard',
loadComponent: () =>
import('./routes/dashboard/dashboard.component').then(
m => m.DashboardComponent,
),
},
{
path: 'service',
loadChildren: () =>
import('./routes/service/service.module').then(m => m.ServiceModule),
},
{
path: 'system',
loadChildren: () =>
import('./routes/system/system.module').then(m => m.SystemModule),
},
],
},
]
export default ROUTES

View File

@@ -0,0 +1,95 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { TuiLetModule } from '@taiga-ui/cdk'
import {
TuiButtonModule,
tuiButtonOptionsProvider,
} from '@taiga-ui/experimental'
import { map } from 'rxjs'
import { UILaunchComponent } from 'src/app/routes/portal/routes/dashboard/ui.component'
import { ActionsService } from 'src/app/services/actions.service'
import { DepErrorService } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
@Component({
standalone: true,
selector: 'fieldset[appControls]',
template: `
@if (pkg().status.main.status === 'running') {
<button
tuiIconButton
iconLeft="tuiIconSquare"
(click)="actions.stop(manifest())"
>
Stop
</button>
<button
tuiIconButton
iconLeft="tuiIconRotateCw"
(click)="actions.restart(manifest())"
>
Restart
</button>
} @else {
<button
*tuiLet="hasUnmet() | async as hasUnmet"
tuiIconButton
iconLeft="tuiIconPlay"
[disabled]="!pkg().status.configured"
(click)="actions.start(manifest(), !!hasUnmet)"
>
Start
</button>
<button
tuiIconButton
iconLeft="tuiIconTool"
(click)="actions.configure(manifest())"
>
Configure
</button>
}
<app-ui-launch [pkg]="pkg()" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButtonModule, UILaunchComponent, TuiLetModule, AsyncPipe],
providers: [tuiButtonOptionsProvider({ size: 's', appearance: 'none' })],
styles: `
:host {
padding: 0;
border: none;
}
:host-context(tui-root._mobile) {
button {
display: none;
}
}
`,
})
export class ControlsComponent {
private readonly errors = inject(DepErrorService)
readonly actions = inject(ActionsService)
pkg = input.required<PackageDataEntry>()
readonly manifest = computed(() => getManifest(this.pkg()))
readonly hasUnmet = computed(() =>
this.errors.getPkgDepErrors$(this.manifest().id).pipe(
map(errors =>
Object.keys(this.pkg().currentDependencies)
.map(id => !!(errors[id] as any)?.[id]) // @TODO fix type
.some(Boolean),
),
),
)
}

View File

@@ -0,0 +1,129 @@
import { DatePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { TuiIconModule } from '@taiga-ui/experimental'
import { map, timer } from 'rxjs'
import { MetricsComponent } from './metrics.component'
import { ServicesComponent } from './services.component'
import { UtilitiesComponent } from './utilities.component'
@Component({
standalone: true,
template: `
<time>{{ date() | date: 'medium' }}</time>
<app-metrics>
<h2>
<tui-icon icon="tuiIconActivity" />
Metrics
</h2>
<div class="g-plaque"></div>
</app-metrics>
<app-utilities>
<h2>
<tui-icon icon="tuiIconSettings" />
Utilities
</h2>
<div class="g-plaque"></div>
</app-utilities>
<app-services>
<h2>
<tui-icon icon="tuiIconGrid" />
Services
</h2>
<div class="g-plaque"></div>
</app-services>
`,
styles: `
:host {
height: calc(100vh - 6rem);
position: relative;
max-width: 64rem;
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
margin: 2rem auto 0;
border: 0.375rem solid transparent;
}
app-metrics,
app-utilities,
app-services {
position: relative;
clip-path: var(--clip-path);
backdrop-filter: blur(1rem);
font-size: 1rem;
}
time {
position: absolute;
left: 22%;
font-weight: bold;
line-height: 1.75rem;
}
h2 {
height: 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
padding: 0 2rem;
font-weight: bold;
font-size: 1rem;
tui-icon {
font-size: 1rem;
}
}
:host-context(tui-root._mobile) {
height: calc(100vh - 7rem);
display: block;
margin: 0;
border-top: 0;
app-metrics,
app-utilities,
app-services {
display: none;
}
time,
h2 {
display: none;
}
}
:host-context(tui-root._mobile [data-dashboard='metrics']) {
app-metrics {
display: block;
}
}
:host-context(tui-root._mobile [data-dashboard='utilities']) {
app-utilities {
display: flex;
align-items: center;
}
}
:host-context(tui-root._mobile main:not([data-dashboard])) {
app-services {
display: block;
margin: 0;
}
}
`,
imports: [
ServicesComponent,
MetricsComponent,
UtilitiesComponent,
TuiIconModule,
DatePipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent {
readonly date = toSignal(timer(0, 1000).pipe(map(() => new Date())))
}

Some files were not shown because too many files have changed in this diff Show More