update/alpha.9 (#2988)

* import marketplac preview for sideload

* fix: improve state service (#2977)

* fix: fix sideload DI

* fix: update Angular

* fix: cleanup

* fix: fix version selection

* Bump node version to fix build for Angular

* misc fixes
- update node to v22
- fix chroot-and-upgrade access to prune-images
- don't self-migrate legacy packages
- #2985
- move dataVersion to volume folder
- remove "instructions.md" from s9pk
- add "docsUrl" to manifest

* version bump

* include flavor when clicking view listing from updates tab

* closes #2980

* fix: fix select button

* bring back ssh keys

* fix: drop 'portal' from all routes

* fix: implement longtap action to select table rows

* fix description for ssh page

* replace instructions with docsLink and refactor marketplace preview

* delete unused translations

* fix patchdb diffing algorithm

* continue refactor of marketplace lib show components

* Booting StartOS instead of Setting up your server on init

* misc fixes
- closes #2990
- closes #2987

* fix build

* docsUrl and clickable service headers

* don't cleanup after update until new service install succeeds

* update types

* misc fixes

* beta.35

* sdkversion, githash for sideload, correct logs for init, startos pubkey display

* bring back reboot button on install

* misc fixes

* beta.36

* better handling of setup and init for websocket errors

* reopen init and setup logs even on graceful closure

* better logging, misc fixes

* fix build

* dont let package stats hang

* dont show docsurl in marketplace if no docsurl

* re-add needs-config

* show error if init fails, shorten hover state on header icons

* fix operator precedemce

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Mariusz Kogen <k0gen@pm.me>
This commit is contained in:
Aiden McClelland
2025-07-18 18:31:12 +00:00
committed by GitHub
parent ba2906a42e
commit 377b7b12ce
237 changed files with 5953 additions and 4777 deletions

View File

@@ -385,7 +385,29 @@
"changeDetection": "OnPush",
"style": "scss",
"skipTests": true,
"skipImport": true
"skipImport": true,
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

3821
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "startos-ui",
"version": "0.4.0-alpha.8",
"version": "0.4.0-alpha.9",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",
@@ -30,34 +30,34 @@
"build-config": "node build-config.js"
},
"dependencies": {
"@angular/animations": "^19.2.11",
"@angular/cdk": "^19.2.16",
"@angular/common": "^19.2.11",
"@angular/compiler": "^19.2.11",
"@angular/core": "^19.2.11",
"@angular/forms": "^19.2.11",
"@angular/platform-browser": "^19.2.11",
"@angular/platform-browser-dynamic": "^19.2.11",
"@angular/pwa": "^19.2.12",
"@angular/router": "^19.2.11",
"@angular/service-worker": "^19.2.11",
"@angular/animations": "^20.1.0",
"@angular/cdk": "^20.1.0",
"@angular/common": "^20.1.0",
"@angular/compiler": "^20.1.0",
"@angular/core": "^20.1.0",
"@angular/forms": "^20.1.0",
"@angular/platform-browser": "^20.1.0",
"@angular/platform-browser-dynamic": "^20.1.0",
"@angular/pwa": "^20.1.0",
"@angular/router": "^20.1.0",
"@angular/service-worker": "^20.1.0",
"@materia-ui/ngx-monaco-editor": "^6.0.0",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"@start9labs/argon2": "^0.3.0",
"@start9labs/start-sdk": "file:../sdk/baseDist",
"@taiga-ui/addon-charts": "4.41.0",
"@taiga-ui/addon-commerce": "4.41.0",
"@taiga-ui/addon-mobile": "4.41.0",
"@taiga-ui/addon-table": "4.41.0",
"@taiga-ui/cdk": "4.41.0",
"@taiga-ui/core": "4.41.0",
"@taiga-ui/addon-charts": "4.44.0",
"@taiga-ui/addon-commerce": "4.44.0",
"@taiga-ui/addon-mobile": "4.44.0",
"@taiga-ui/addon-table": "4.44.0",
"@taiga-ui/cdk": "4.44.0",
"@taiga-ui/core": "4.44.0",
"@taiga-ui/event-plugins": "4.6.0",
"@taiga-ui/experimental": "4.41.0",
"@taiga-ui/icons": "4.41.0",
"@taiga-ui/kit": "4.41.0",
"@taiga-ui/layout": "4.41.0",
"@taiga-ui/legacy": "4.41.0",
"@taiga-ui/experimental": "4.44.0",
"@taiga-ui/icons": "4.44.0",
"@taiga-ui/kit": "4.44.0",
"@taiga-ui/layout": "4.44.0",
"@taiga-ui/legacy": "4.44.0",
"@taiga-ui/polymorpheus": "4.9.0",
"@taiga-ui/dompurify": "4.1.11",
"ansi-to-html": "^0.7.2",
@@ -78,7 +78,7 @@
"mime": "^4.0.3",
"monaco-editor": "^0.33.0",
"mustache": "^4.2.0",
"ng-qrcode": "^19.0.1",
"ng-qrcode": "^20.0.0",
"node-jose": "^2.2.0",
"patch-db-client": "file:../patch-db/client",
"pbkdf2": "^3.1.2",
@@ -89,10 +89,10 @@
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular/build": "^19.2.12",
"@angular/cli": "^19.2.12",
"@angular/compiler-cli": "^19.2.11",
"@angular/language-service": "^19.2.11",
"@angular/build": "^20.1.0",
"@angular/cli": "^20.1.0",
"@angular/compiler-cli": "^20.1.0",
"@angular/language-service": "^20.1.0",
"@angular-experts/hawkeye": "^1.7.2",
"@types/dompurify": "3.0.5",
"@types/estree": "^0.0.51",
@@ -105,7 +105,7 @@
"@types/uuid": "^8.3.1",
"husky": "^4.3.8",
"lint-staged": "^13.2.0",
"ng-packagr": "^19.2.2",
"ng-packagr": "^20.1.0",
"node-html-parser": "^5.3.3",
"postcss": "^8.4.21",
"prettier": "^3.5.3",

View File

@@ -5,6 +5,5 @@
"https://community-registry.start9.com/": "Community Registry"
},
"startosRegistry": "https://registry.start9.com/",
"snakeHighScore": 0,
"ackInstructions": {}
"snakeHighScore": 0
}

View File

@@ -5,7 +5,6 @@ import { TuiConfirmData } from '@taiga-ui/kit'
export const SUCCESS: Partial<TuiDialogOptions<any>> = {
label: 'Install Success',
closeable: false,
dismissible: false,
size: 's',
data: { button: 'Reboot' },
}

View File

@@ -4,20 +4,20 @@ import { knownRegistries, sameUrl } from '@start9labs/shared'
@Component({
selector: 'store-icon',
template: `
<img
*ngIf="icon; else noIcon"
[style.border-radius.%]="100"
[style.max-width]="size || '100%'"
[src]="icon"
alt="Registry Icon"
/>
<ng-template #noIcon>
@if (icon) {
<img
[style.border-radius.%]="100"
[style.max-width]="size || '100%'"
[src]="icon"
alt="Registry Icon"
/>
} @else {
<img
[style.max-width]="size || '100%'"
src="assets/img/storefront-outline.png"
alt="Registry Icon"
/>
</ng-template>
}
`,
styles: ':host { overflow: hidden; }',
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -1,52 +0,0 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { Exver, MarkdownPipe } from '@start9labs/shared'
import { TuiDialogContext } from '@taiga-ui/core'
import { NgDompurifyPipe } from '@taiga-ui/dompurify'
import { TuiAccordion } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { MarketplacePkg } from '../../src/types'
@Component({
template: `
<tui-accordion>
@for (note of notes | keyvalue: asIsOrder; track $index) {
<tui-accordion-item>
{{ note.key }}
<ng-template tuiAccordionItemContent>
<div [innerHTML]="note.value | markdown | dompurify"></div>
</ng-template>
</tui-accordion-item>
}
</tui-accordion>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiAccordion, MarkdownPipe, NgDompurifyPipe],
})
export class ReleaseNotesComponent {
private readonly exver = inject(Exver)
private readonly pkg =
injectContext<TuiDialogContext<void, MarketplacePkg>>().data
readonly notes = Object.entries(this.pkg.otherVersions)
.filter(
([v, _]) =>
this.exver.getFlavor(v) === this.pkg.flavor &&
this.exver.compareExver(this.pkg.version, v) === 1,
)
.reduce(
(obj, [version, info]) => ({
...obj,
[version]: info.releaseNotes,
}),
{
[`${this.pkg.version} (current)`]: this.pkg.releaseNotes,
},
)
asIsOrder(a: any, b: any) {
return 0
}
}
export const RELEASE_NOTES = new PolymorpheusComponent(ReleaseNotesComponent)

View File

@@ -16,8 +16,8 @@
position: absolute;
width: 100%;
height: 100%;
top: 0px;
left: 0px;
top: 0;
left: 0;
z-index: -50;
border-radius: 1.5rem;
background-color: rgb(39 39 42);

View File

@@ -0,0 +1,139 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
} from '@angular/core'
import { MarketplacePkgBase } from '../../types'
import { CopyService } from '@start9labs/shared'
import { DatePipe } from '@angular/common'
import { MarketplaceItemComponent } from './item.component'
@Component({
selector: 'marketplace-about',
template: `
<div class="background-border box-shadow-lg shadow-color-light">
<div class="box-container">
<div class="detail-container">
<!-- version -->
<marketplace-item
[style.pointer-events]="'none'"
[data]="pkg().version"
label="Version"
icon=""
/>
<!-- release date -->
@if (pkg().s9pk?.publishedAt; as published) {
<marketplace-item
[style.pointer-events]="'none'"
[data]="(published | date: 'medium')!"
label="Released"
icon=""
/>
}
<!-- SDK version -->
<marketplace-item
[style.pointer-events]="'none'"
[data]="pkg().sdkVersion || 'Unknown'"
label="SDK Version"
icon=""
/>
<!-- git hash -->
@if (pkg().gitHash; as gitHash) {
<marketplace-item
(click)="copyService.copy(gitHash)"
[data]="gitHash"
label="Git Hash"
icon="@tui.copy"
class="item-copy"
/>
} @else {
<div class="item-padding">
<label tuiTitle>
<span tuiSubtitle>Git Hash</span>
Unknown
</label>
</div>
}
<!-- license -->
<marketplace-item
(click)="static.emit('license')"
[data]="pkg().license"
label="License"
icon="@tui.chevron-right"
class="item-pointer"
/>
</div>
</div>
</div>
<div class="background-border box-shadow-lg shadow-color-light">
<div class="box-container">
<p [innerHTML]="pkg().description.long"></p>
</div>
</div>
`,
styles: `
.box-container {
background-color: rgb(39 39 42);
border-radius: 0.75rem;
padding: 1.25rem 1.75rem;
p {
font-size: 1rem;
line-height: 1.5rem;
margin-bottom: 0.75rem;
pointer-events: none;
}
}
.detail-container {
display: grid;
grid-auto-flow: row;
grid-auto-columns: minmax(0, 1fr);
& > * + * {
border-top-width: 1px;
border-bottom-width: 0;
border-color: rgb(113 113 122);
}
}
.item-pointer:hover {
cursor: pointer;
::ng-deep label {
cursor: pointer;
}
}
.item-copy:hover {
cursor: copy;
::ng-deep label {
cursor: copy;
}
}
.item-padding {
padding: 0.75rem 0.25rem;
}
* {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: rgb(var(--tw-color-gray-200) / 1);
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MarketplaceItemComponent, DatePipe],
})
export class MarketplaceAboutComponent {
readonly copyService = inject(CopyService)
readonly pkg = input.required<MarketplacePkgBase>()
readonly static = output<'license'>()
}

View File

@@ -1,29 +0,0 @@
<div class="background-border box-shadow-lg shadow-color-light">
<div class="box-container">
<h2 class="additional-detail-title">New in {{ pkg.version }}</h2>
<p [innerHTML]="pkg.releaseNotes | markdown | dompurify"></p>
<button
tuiButton
size="s"
appearance="whiteblock"
iconEnd="@tui.chevron-right"
(click)="onPast()"
>
Past Release Notes
</button>
<h2 class="additional-detail-title" [style.margin-top.rem]="2">About</h2>
<p [innerHTML]="pkg.description.long"></p>
<a
*ngIf="pkg.marketingSite as url"
tuiButton
iconEnd="@tui.external-link"
size="s"
appearance="secondary-grayscale"
target="_blank"
rel="noreferrer"
[href]="url"
>
View website
</a>
</div>
</div>

View File

@@ -1,12 +0,0 @@
.box-container {
background-color: rgb(39 39 42);
border-radius: 0.75rem;
padding: 1.75rem;
p {
font-size: 1rem;
line-height: 1.5rem;
margin-bottom: 0.75rem;
pointer-events: none;
}
}

View File

@@ -1,29 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { TuiDialogService } from '@taiga-ui/core'
import { RELEASE_NOTES } from '../../../modals/release-notes.component'
import { MarketplacePkgBase } from '../../../types'
@Component({
selector: 'marketplace-about',
templateUrl: 'about.component.html',
styleUrls: ['about.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class AboutComponent {
private readonly dialogs = inject(TuiDialogService)
@Input({ required: true })
pkg!: MarketplacePkgBase
async onPast() {
this.dialogs
.open(RELEASE_NOTES, { label: 'Past Release Notes', data: this.pkg })
.subscribe()
}
}

View File

@@ -1,23 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { MarkdownPipe, SafeLinksDirective } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { NgDompurifyPipe } from '@taiga-ui/dompurify'
import { TuiTagModule } from '@taiga-ui/legacy'
import { AboutComponent } from './about.component'
@NgModule({
imports: [
CommonModule,
RouterModule,
TuiTagModule,
NgDompurifyPipe,
SafeLinksDirective,
MarkdownPipe,
TuiButton,
],
declarations: [AboutComponent],
exports: [AboutComponent],
})
export class AboutModule {}

View File

@@ -1,28 +0,0 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplaceAdditionalItemComponent } from './additional-item.component'
@Component({
selector: 'marketplace-additional-link',
template: `
<a [href]="url" target="_blank" rel="noreferrer">
<marketplace-additional-item
[label]="label"
[icon]="icon"
[data]="url"
></marketplace-additional-item>
</a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, MarketplaceAdditionalItemComponent],
})
export class MarketplaceAdditionalLinkComponent {
@Input({ required: true })
label!: string
@Input({ required: true })
icon!: string
@Input({ required: true })
url!: string
}

View File

@@ -1,78 +0,0 @@
<div class="background-border shadow-color-light box-shadow-lg">
<div class="box-container">
<h2 class="additional-detail-title">Information</h2>
<div class="detail-container">
<!-- release date -->
<marketplace-additional-item
*ngIf="pkg.s9pk?.publishedAt as published"
[data]="(published | date: 'medium')!"
label="Released"
icon=""
/>
<!-- git hash -->
<marketplace-additional-item
*ngIf="pkg.gitHash as gitHash; else noHash"
(click)="copyService.copy(gitHash)"
[data]="gitHash"
label="Git Hash"
icon="@tui.copy"
class="item-copy"
/>
<ng-template #noHash>
<div class="item-padding">
<label tuiTitle>
<span tuiSubtitle>Git Hash</span>
Unknown
</label>
</div>
</ng-template>
<!-- license -->
<marketplace-additional-item
(click)="static.emit('license')"
[data]="pkg.license"
label="License"
icon="@tui.chevron-right"
class="item-pointer"
/>
<!-- instructions -->
<marketplace-additional-item
(click)="static.emit('instructions')"
data="Click to view instructions"
label="Instructions"
icon="@tui.chevron-right"
class="item-pointer"
/>
<!-- versions -->
<ng-content />
<!-- links -->
<marketplace-additional-link
*ngIf="pkg.marketingSite"
[url]="pkg.marketingSite"
label="Marketing Site"
icon="@tui.external-link"
class="item-pointer"
/>
<marketplace-additional-link
*ngIf="pkg.upstreamRepo"
[url]="pkg.upstreamRepo"
label="Source Repository"
icon="@tui.external-link"
class="item-pointer"
/>
<marketplace-additional-link
*ngIf="pkg.wrapperRepo"
[url]="pkg.wrapperRepo"
label="Wrapper Repository"
icon="@tui.external-link"
class="item-pointer"
/>
<marketplace-additional-link
*ngIf="pkg.supportSite"
[url]="pkg.supportSite"
label="Support Site"
icon="@tui.external-link"
class="item-pointer"
/>
</div>
</div>
</div>

View File

@@ -1,46 +0,0 @@
.box-container {
background-color: rgb(39 39 42);
border-radius: 0.75rem;
padding: 1.75rem;
}
.detail-container {
display: grid;
grid-auto-flow: row;
grid-auto-columns: minmax(0, 1fr);
& > * + * {
border-top-width: 1px;
border-bottom-width: 0;
border-color: rgb(113 113 122);
}
}
.item-pointer:hover {
cursor: pointer;
::ng-deep label {
cursor: pointer;
}
}
.item-copy:hover {
cursor: copy;
::ng-deep label {
cursor: copy;
}
}
.item-padding {
padding: 0.75rem 0.25rem;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: rgb(var(--tw-color-gray-200) / 1);
}

View File

@@ -1,32 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { CopyService } from '@start9labs/shared'
import { MarketplacePkgBase } from '../../../types'
@Component({
selector: 'marketplace-additional',
templateUrl: 'additional.component.html',
styleUrls: ['additional.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class AdditionalComponent {
@Input({ required: true })
pkg!: MarketplacePkgBase
@Output()
readonly static = new EventEmitter<'license' | 'instructions'>()
constructor(
readonly copyService: CopyService,
private readonly route: ActivatedRoute,
) {}
readonly url = this.route.snapshot.queryParamMap.get('url') || undefined
}

View File

@@ -1,20 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { AdditionalComponent } from './additional.component'
import { TuiButton, TuiLabel, TuiTitle } from '@taiga-ui/core'
import { MarketplaceAdditionalItemComponent } from './additional-item.component'
import { MarketplaceAdditionalLinkComponent } from './additional-link.component'
@NgModule({
imports: [
CommonModule,
TuiButton,
TuiLabel,
MarketplaceAdditionalItemComponent,
MarketplaceAdditionalLinkComponent,
TuiTitle,
],
declarations: [AdditionalComponent],
exports: [AdditionalComponent],
})
export class AdditionalModule {}

View File

@@ -1,4 +1,4 @@
import { CommonModule, KeyValue } from '@angular/common'
import { KeyValue } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterModule } from '@angular/router'
import { ExverPipesModule } from '@start9labs/shared'
@@ -19,10 +19,11 @@ import { MarketplacePkgBase } from '../../../types'
{{ getTitle(dep.key) }}
</span>
<p>
<ng-container [ngSwitch]="dep.value.optional">
<span *ngSwitchCase="true">(optional)</span>
<span *ngSwitchCase="false">(required)</span>
</ng-container>
@if (dep.value.optional) {
<span>(optional)</span>
} @else {
<span>(required)</span>
}
</p>
</div>
</ng-template>
@@ -87,13 +88,7 @@ import { MarketplacePkgBase } from '../../../types'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
RouterModule,
TuiAvatar,
ExverPipesModule,
TuiLineClamp,
],
imports: [RouterModule, TuiAvatar, ExverPipesModule, TuiLineClamp],
})
export class MarketplaceDepItemComponent {
@Input({ required: true })

View File

@@ -4,7 +4,7 @@ import { SharedPipesModule } from '@start9labs/shared'
import { TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { MarketplacePkg } from '../../../types'
import { MarketplacePkg } from '../../types'
@Component({
selector: 'marketplace-flavors',
@@ -33,7 +33,7 @@ import { MarketplacePkg } from '../../../types'
.box-container {
background-color: rgb(39 39 42);
border-radius: 0.75rem;
padding: 1.75rem;
padding: 1.25rem 1.75rem;
}
[tuiCell] {
@@ -44,7 +44,7 @@ import { MarketplacePkg } from '../../../types'
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, TuiCell, TuiTitle, SharedPipesModule, TuiAvatar],
})
export class FlavorsComponent {
export class MarketplaceFlavorsComponent {
@Input()
pkgs!: MarketplacePkg[]
}

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
@@ -17,8 +16,6 @@ import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
<div class="dark-overlay"></div>
<div class="inner-container-title">
<h2 ticker>{{ pkg.title }}</h2>
<h3>{{ pkg.version }}</h3>
<p>{{ pkg.description.short }}</p>
</div>
<!-- control buttons -->
<ng-content />
@@ -43,7 +40,7 @@ import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
min-height: 32vh;
position: relative;
border-radius: 1.5rem;
padding: 4rem 2rem 0 2rem;
padding: 3rem 2rem 0 2rem;
@media (min-width: 376px) {
min-height: 20vh;
@@ -66,7 +63,7 @@ import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
}
.inner-container-title {
margin: 1rem 0;
margin: 0.5rem 0 1rem 0;
color: rgb(250 250 250);
mix-blend-mode: plus-lighter;
z-index: 1;
@@ -77,29 +74,11 @@ import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
}
h2 {
font-size: 2rem;
font-size: 2.5rem;
line-height: 3rem;
font-weight: normal;
display: inline-block;
margin-left: -1px;
}
h3 {
font-size: 1.1rem;
font-weight: normal;
margin-bottom: 1rem;
pointer-events: none;
}
p {
font-size: 1rem;
line-height: 1.5rem;
font-weight: 300;
pointer-events: none;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
margin: 1rem 0 1rem 0;
}
}
@@ -138,7 +117,7 @@ import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, SharedPipesModule, TickerComponent],
imports: [SharedPipesModule, TickerComponent],
})
export class MarketplacePackageHeroComponent {
@Input({ required: true })

View File

@@ -1,10 +1,9 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiFade } from '@taiga-ui/kit'
@Component({
selector: 'marketplace-additional-item',
selector: 'marketplace-item',
template: `
<label tuiTitle>
<span tuiSubtitle>{{ label }}</span>
@@ -35,9 +34,9 @@ import { TuiFade } from '@taiga-ui/kit'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiIcon, TuiTitle, TuiFade],
imports: [TuiIcon, TuiTitle, TuiFade],
})
export class MarketplaceAdditionalItemComponent {
export class MarketplaceItemComponent {
@Input({ required: true })
label!: string

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplaceItemComponent } from './item.component'
@Component({
selector: 'marketplace-link',
template: `
<a [href]="url" target="_blank" rel="noreferrer">
<marketplace-item [label]="label" [icon]="icon" [data]="url" />
</a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MarketplaceItemComponent],
})
export class MarketplaceLinkComponent {
@Input({ required: true })
label!: string
@Input({ required: true })
icon!: string
@Input({ required: true })
url!: string
}

View File

@@ -0,0 +1,128 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { CopyService } from '@start9labs/shared'
import { MarketplacePkgBase } from '../../types'
import { MarketplaceLinkComponent } from './link.component'
@Component({
selector: 'marketplace-links',
template: `
<div class="background-border shadow-color-light box-shadow-lg">
<div class="box-container">
<h2 class="additional-detail-title">Source Code</h2>
<div class="detail-container">
<marketplace-link
[url]="pkg().upstreamRepo"
label="Upstream Service"
icon="@tui.external-link"
class="item-pointer"
/>
<marketplace-link
[url]="pkg().wrapperRepo"
label="StartOS Package"
icon="@tui.external-link"
class="item-pointer"
/>
</div>
</div>
</div>
<div class="background-border shadow-color-light box-shadow-lg">
<div class="box-container">
<h2 class="additional-detail-title">Links</h2>
<div class="detail-container">
<marketplace-link
[url]="pkg().marketingSite"
label="Marketing"
icon="@tui.external-link"
class="item-pointer"
/>
@if (pkg().docsUrl; as docsUrl) {
<marketplace-link
[url]="docsUrl"
label="Documentation"
icon="@tui.external-link"
class="item-pointer"
/>
}
<marketplace-link
[url]="pkg().supportSite"
label="Support"
icon="@tui.external-link"
class="item-pointer"
/>
@if (pkg().donationUrl; as donationUrl) {
<marketplace-link
[url]="donationUrl"
label="Donations"
icon="@tui.external-link"
class="item-pointer"
/>
}
</div>
</div>
</div>
`,
styles: `
.box-container {
background-color: rgb(39 39 42);
border-radius: 0.75rem;
padding: 1.25rem 1.75rem;
}
.detail-container {
display: grid;
grid-auto-flow: row;
grid-auto-columns: minmax(0, 1fr);
& > * + * {
border-top-width: 1px;
border-bottom-width: 0;
border-color: rgb(113 113 122);
}
}
.item-pointer:hover {
cursor: pointer;
::ng-deep label {
cursor: pointer;
}
}
.item-copy:hover {
cursor: copy;
::ng-deep label {
cursor: copy;
}
}
.item-padding {
padding: 0.75rem 0.25rem;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: rgb(var(--tw-color-gray-200) / 1);
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MarketplaceLinkComponent],
})
export class MarketplaceLinksComponent {
readonly copyService = inject(CopyService)
readonly url =
inject(ActivatedRoute).snapshot.queryParamMap.get('url') || undefined
readonly pkg = input.required<MarketplacePkgBase>()
}

View File

@@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { MarkdownPipe } from '@start9labs/shared'
import { NgDompurifyPipe } from '@taiga-ui/dompurify'
import { MarketplacePkgBase } from '../../types'
@Component({
selector: 'marketplace-release-notes',
template: `
<div class="background-border box-shadow-lg shadow-color-light">
<div class="box-container">
<h2 class="additional-detail-title">New in {{ pkg().version }}</h2>
<p [innerHTML]="pkg().releaseNotes | markdown | dompurify"></p>
</div>
</div>
`,
styles: `
.box-container {
background-color: rgb(39 39 42);
border-radius: 0.75rem;
padding: 1.25rem 1.75rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgDompurifyPipe, MarkdownPipe],
})
export class MarketplaceReleaseNotesComponent {
readonly pkg = input.required<MarketplacePkgBase>()
}

View File

@@ -1,5 +1,3 @@
import { TuiCarousel } from '@taiga-ui/kit'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -7,73 +5,72 @@ import {
Input,
} from '@angular/core'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiDialogContext, TuiDialogService, TuiButton } from '@taiga-ui/core'
import { MarketplacePkg } from '../../../types'
import { TuiButton, TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
import { TuiCarousel } from '@taiga-ui/kit'
import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
import { MarketplacePkg } from '../../types'
@Component({
selector: 'marketplace-package-screenshots',
template: `
<!--@TODO future release-->
<div
*ngIf="$any(pkg).screenshots as screenshots"
tuiCarouselButtons
class="outer-container"
>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.chevron-left"
title="Previous"
type="button"
(click)="carousel.prev()"
></button>
<tui-carousel
#carousel
[itemsCount]="isMobile ? 1 : 2"
[(index)]="index"
class="carousel"
>
<ng-container *ngFor="let item of screenshots; let i = index">
<div
*tuiItem
draggable="false"
[class.item_active]="i === index + 1"
class="screenshot-item"
>
<img
#template
alt="Service screenshot"
src="assets/img/temp/{{ item }}"
class="screenshot-item-img"
(click)="presentModalImg(dialogTemplate)"
/>
<ng-template #dialogTemplate let-observer>
@if ($any(pkg).screenshots; as screenshots) {
<div tuiCarouselButtons class="outer-container">
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.chevron-left"
title="Previous"
type="button"
(click)="carousel.prev()"
></button>
<tui-carousel
#carousel
[itemsCount]="isMobile ? 1 : 2"
[(index)]="index"
class="carousel"
>
@for (item of screenshots; track item; let i = $index) {
<div
*tuiItem
draggable="false"
[class.item_active]="i === index + 1"
class="screenshot-item"
>
<img
#template
alt="Service screenshot"
src="assets/img/temp/{{ item }}"
class="screenshot-item-img-enlarged"
class="screenshot-item-img"
(click)="presentModalImg(dialogTemplate)"
/>
</ng-template>
</div>
</ng-container>
</tui-carousel>
<button
tuiIconButton
appearance="flat-grayscale"
type="button"
iconStart="@tui.chevron-right"
title="Next"
(click)="carousel.next()"
></button>
</div>
<ng-template #dialogTemplate let-observer>
<img
alt="Service screenshot"
src="assets/img/temp/{{ item }}"
class="screenshot-item-img-enlarged"
/>
</ng-template>
</div>
}
</tui-carousel>
<button
tuiIconButton
appearance="flat-grayscale"
type="button"
iconStart="@tui.chevron-right"
title="Next"
(click)="carousel.next()"
></button>
</div>
}
`,
styles: `
.outer-container {
display: flex;
align-items: center;
align-content: center;
margin: 0px;
margin: 0;
@media (min-width: 1024px) {
margin-left: -3.5rem;
@@ -123,7 +120,7 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiCarousel, TuiButton],
imports: [TuiCarousel, TuiButton],
})
export class MarketplacePackageScreenshotComponent {
private readonly dialogs = inject(TuiDialogService)

View File

@@ -0,0 +1,102 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
TemplateRef,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { DialogService, i18nPipe, SharedPipesModule } from '@start9labs/shared'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { TuiRadioList } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { MarketplaceItemComponent } from './item.component'
@Component({
selector: 'marketplace-versions',
template: `
<div class="background-border shadow-color-light box-shadow-lg">
<div class="box-container">
<h2 class="additional-detail-title">Versions</h2>
<marketplace-item
(click)="promptSelectVersion(versionSelect)"
data="Select another version"
icon="@tui.chevron-right"
label=""
class="select"
/>
<ng-template
#versionSelect
let-data="data"
let-completeWith="completeWith"
>
<tui-radio-list [items]="versions()" [(ngModel)]="data.version" />
<footer class="buttons">
<button
tuiButton
appearance="secondary"
(click)="completeWith(null)"
>
{{ 'Cancel' | i18n }}
</button>
<button
tuiButton
appearance="secondary"
(click)="completeWith(data.version)"
>
{{ 'Ok' | i18n }}
</button>
</footer>
</ng-template>
</div>
</div>
`,
styles: `
.box-container {
background-color: rgb(39 39 42);
border-radius: 0.75rem;
padding: 1.25rem 1.75rem;
}
.select {
border: 0;
// border-top-width: 1px;
border-bottom-width: 1px;
border-color: rgb(113 113 122);
border-style: solid;
cursor: pointer;
::ng-deep label {
cursor: pointer;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MarketplaceItemComponent,
TuiButton,
SharedPipesModule,
FormsModule,
TuiRadioList,
i18nPipe,
],
})
export class MarketplaceVersionsComponent {
private readonly dialog = inject(DialogService)
readonly version = input.required<string | null>()
readonly versions = input.required<string[]>()
onVersion = output<string>()
promptSelectVersion(template: TemplateRef<TuiDialogContext>) {
this.dialog
.openComponent<string>(template, {
label: 'All versions',
size: 's',
data: { version: this.version() },
})
.pipe(filter(Boolean))
.subscribe(selected => this.onVersion.emit(selected))
}
}

View File

@@ -8,17 +8,17 @@ export * from './pages/list/item/item.component'
export * from './pages/list/item/item.module'
export * from './pages/list/search/search.component'
export * from './pages/list/search/search.module'
export * from './pages/show/about/about.component'
export * from './pages/show/about/about.module'
export * from './pages/show/additional/additional-link.component'
export * from './pages/show/additional/additional-item.component'
export * from './pages/show/additional/additional.component'
export * from './pages/show/additional/additional.module'
export * from './pages/show/link.component'
export * from './pages/show/item.component'
export * from './pages/show/links.component'
export * from './pages/show/dependencies/dependencies.component'
export * from './pages/show/dependencies/dependency-item.component'
export * from './pages/show/screenshots/screenshots.component'
export * from './pages/show/hero/hero.component'
export * from './pages/show/flavors/flavors.component'
export * from './pages/show/about.component'
export * from './pages/show/screenshots.component'
export * from './pages/show/hero.component'
export * from './pages/show/flavors.component'
export * from './pages/show/versions.component'
export * from './pages/show/release-notes.component'
export * from './pipes/filter-packages.pipe'

View File

@@ -1,9 +1,8 @@
import { Component, inject } from '@angular/core'
import { Component, inject, DOCUMENT } from '@angular/core'
import { Router } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from './services/state.service'
import { DOCUMENT } from '@angular/common'
@Component({
selector: 'app-root',

View File

@@ -65,8 +65,8 @@ import { DocsLinkDirective } from '@start9labs/shared'
text-align: center;
border-radius: clamp(2rem, 3rem, 4rem);
cursor: pointer;
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px,
rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
box-shadow: rgba(0, 0, 0, 0.1) 0 4px 6px -1px,
rgba(0, 0, 0, 0.06) 0 2px 4px -1px;
background: #6866cc;
color: #f4f4f5;
"

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { RouterModule } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
@@ -102,7 +101,6 @@ import { StateService } from 'src/app/services/state.service'
}
`,
imports: [
CommonModule,
RouterModule,
TuiCardLarge,
TuiButton,

View File

@@ -1,29 +1,33 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { Router } from '@angular/router'
import {
ErrorService,
formatProgress,
getErrorMessage,
InitializingComponent,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
catchError,
EMPTY,
filter,
from,
interval,
map,
startWith,
switchMap,
take,
tap,
timer,
} from 'rxjs'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
template: '<app-initializing [setupType]="type" [progress]="progress()" />',
template:
'<app-initializing [setupType]="type" [progress]="progress()" [error]="error()" />',
styles: `
:host {
max-width: unset;
@@ -35,25 +39,15 @@ import { StateService } from 'src/app/services/state.service'
})
export default class LoadingPage {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
readonly type = inject(StateService).setupType
readonly router = inject(Router)
readonly progress = toSignal(
from(this.getStatus()).pipe(
filter(Boolean),
take(1),
switchMap(({ guid, progress }) =>
this.api.openWebsocket$<T.FullProgress>(guid).pipe(
startWith(progress),
catchError((_, watch$) =>
interval(2000).pipe(
switchMap(() => from(this.api.getStatus())),
catchError(() => EMPTY),
take(1),
switchMap(() => watch$),
),
),
tap(({ overall }) => {
if (overall === true) {
this.getStatus()
@@ -62,29 +56,32 @@ export default class LoadingPage {
),
),
map(formatProgress),
catchError(e => {
this.errorService.handleError(e)
return EMPTY
}),
catchError((_, caught$) => timer(500).pipe(switchMap(() => caught$))),
),
{ initialValue: { total: 0, message: '' } },
)
error = signal('')
private async getStatus(): Promise<{
status: 'running'
guid: string
progress: T.FullProgress
} | null> {
const res = await this.api.getStatus()
try {
const res = await this.api.getStatus()
if (!res) {
this.router.navigate(['home'])
return null
} else if (res.status === 'complete') {
this.router.navigate(['success'])
return null
} else {
return res
if (!res) {
this.router.navigate(['home'])
} else if (res.status === 'complete') {
this.router.navigate(['success'])
} else {
return res
}
} catch (e: any) {
this.error.set(getErrorMessage(e))
}
return null
}
}

View File

@@ -1,10 +1,10 @@
import { DOCUMENT } from '@angular/common'
import {
AfterViewInit,
Component,
ElementRef,
inject,
ViewChild,
DOCUMENT,
} from '@angular/core'
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
import { TuiButton, TuiIcon, TuiSurface } from '@taiga-ui/core'

View File

@@ -23,7 +23,7 @@ export abstract class ApiService {
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
abstract complete(): Promise<T.SetupResult> // setup.complete
abstract exit(): Promise<void> // setup.exit
abstract followServerLogs(): Promise<FollowLogsRes> // setup.logs.follow
abstract initFollowLogs(): Promise<FollowLogsRes> // setup.logs.follow
abstract openWebsocket$<T>(guid: string): Observable<T>
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {

View File

@@ -1,5 +1,4 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { Inject, Injectable, DOCUMENT } from '@angular/core'
import {
DiskListResponse,
encodeBase64,
@@ -98,7 +97,7 @@ export class LiveApiService extends ApiService {
})
}
async followServerLogs(): Promise<FollowLogsRes> {
async initFollowLogs(): Promise<FollowLogsRes> {
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
}

View File

@@ -302,7 +302,7 @@ export class MockApiService extends ApiService {
}
}
async followServerLogs(): Promise<FollowLogsRes> {
async initFollowLogs(): Promise<FollowLogsRes> {
await pauseFor(1000)
return {
startCursor: 'fakestartcursor',

View File

@@ -1,61 +0,0 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiProgress } from '@taiga-ui/kit'
import { LogsWindowComponent } from './logs-window.component'
import { i18nPipe } from '../i18n/i18n.pipe'
@Component({
selector: 'app-initializing',
template: `
<section>
<h1 [style.font-size.rem]="2" [style.margin-bottom.rem]="2">
{{ 'Setting up your server' | i18n }}
</h1>
<div>
{{ 'Progress' | i18n }}: {{ (progress.total * 100).toFixed(0) }}%
</div>
<progress
tuiProgressBar
[style.max-width.rem]="40"
[style.margin]="'1rem auto'"
[attr.value]="progress.total"
></progress>
<p [innerHTML]="progress.message || 'Finished'"></p>
</section>
<logs-window />
`,
styles: `
section {
border-radius: 0.25rem;
padding: 1rem;
margin: 1.5rem;
text-align: center;
// @TODO Theme
background: #e0e0e0;
color: #333;
--tui-background-neutral-1: rgba(0, 0, 0, 0.1);
}
logs-window {
display: flex;
flex-direction: column;
height: 18rem;
padding: 1rem;
margin: 0 1.5rem auto;
text-align: left;
overflow: hidden;
border-radius: 2rem;
// @TODO Theme
background: #181818;
}
`,
imports: [CommonModule, LogsWindowComponent, TuiProgress, i18nPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InitializingComponent {
@Input()
progress: { total: number; message: string } = { total: 0, message: '' }
@Input()
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
}

View File

@@ -0,0 +1,92 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
Input,
} from '@angular/core'
import { TuiProgress } from '@taiga-ui/kit'
import { LogsWindowComponent } from './logs-window.component'
import { i18nPipe } from '../../i18n/i18n.pipe'
@Component({
selector: 'app-initializing',
template: `
@if (error(); as err) {
<section>
<h1>{{ 'Error initializing server' | i18n }}</h1>
<p>{{ err }}</p>
</section>
} @else {
<section>
<h1 [style.font-size.rem]="2" [style.margin-bottom.rem]="2">
{{
setupType()
? ('Setting up your server' | i18n)
: ('Booting StartOS' | i18n)
}}
</h1>
<div>
{{ 'Progress' | i18n }}: {{ (progress().total * 100).toFixed(0) }}%
</div>
<progress
tuiProgressBar
[style.max-width.rem]="40"
[style.margin]="'1rem auto'"
[attr.value]="progress().total"
></progress>
<p [innerHTML]="message()"></p>
</section>
}
<logs-window />
`,
styles: `
section {
border-radius: 0.25rem;
padding: 1rem;
margin: 1.5rem;
text-align: center;
// @TODO Theme
background: #e0e0e0;
color: #333;
--tui-background-neutral-1: rgba(0, 0, 0, 0.1);
}
logs-window {
display: flex;
flex-direction: column;
height: 18rem;
padding: 1rem;
margin: 0 1.5rem auto;
text-align: left;
overflow: hidden;
border-radius: 2rem;
// @TODO Theme
background: #181818;
}
`,
imports: [LogsWindowComponent, TuiProgress, i18nPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InitializingComponent {
private readonly i18nPipe = inject(i18nPipe)
readonly progress = input<{ total: number; message: string }>({
total: 0,
message: '',
})
readonly setupType = input<
'fresh' | 'restore' | 'attach' | 'transfer' | undefined
>()
readonly error = input<string>()
readonly message = computed(() => {
return (
this.progress().message ||
(this.progress().total === 1
? this.i18nPipe.transform('Finished')
: '...')
)
})
}

View File

@@ -1,5 +1,5 @@
import { AsyncPipe } from '@angular/common'
import { Component, ElementRef, inject } from '@angular/core'
import { Component, ElementRef, inject, input } from '@angular/core'
import {
INTERSECTION_ROOT,
WaIntersectionObserver,
@@ -7,7 +7,7 @@ import {
import { WaMutationObserver } from '@ng-web-apis/mutation-observer'
import { TuiScrollbar } from '@taiga-ui/core'
import { NgDompurifyPipe } from '@taiga-ui/dompurify'
import { SetupLogsService } from '../services/setup-logs.service'
import { SetupLogsService } from '../../services/setup-logs.service'
@Component({
selector: 'logs-window',

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiAutoFocus } from '@taiga-ui/cdk'
@@ -14,7 +13,9 @@ import { i18nKey } from '../i18n/i18n.providers'
@Component({
template: `
<p>{{ options.message }}</p>
<p *ngIf="options.warning" class="warning">{{ options.warning }}</p>
@if (options.warning) {
<p class="warning">{{ options.warning }}</p>
}
<form (ngSubmit)="submit(value.trim())">
<tui-input
tuiAutoFocus
@@ -24,7 +25,9 @@ import { i18nKey } from '../i18n/i18n.providers'
[(ngModel)]="value"
>
{{ options.label }}
<span *ngIf="options.required !== false && options.label">*</span>
@if (options.required !== false && options.label) {
<span>*</span>
}
<input
tuiTextfieldLegacy
[class.masked]="options.useMask && masked && value"
@@ -74,7 +77,6 @@ import { i18nKey } from '../i18n/i18n.providers'
}
`,
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiButton,

View File

@@ -1,5 +1,4 @@
import { DOCUMENT } from '@angular/common'
import { Directive, inject } from '@angular/core'
import { Directive, inject, DOCUMENT } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import {
MutationObserverService,

View File

@@ -274,11 +274,9 @@ export default {
273: 'Git-Hash',
274: 'Lizenz',
275: 'Installiert von',
276: 'Dienst-Repository',
277: 'Paket-Repository',
278: 'Marketing-Website',
279: 'Support-Website',
280: 'Spendenlink',
278: 'Marketing',
279: 'Support',
280: 'Spenden',
281: 'Standardaktionen',
282: 'Dienst neu bauen',
283: 'Baut den Dienst-Container neu. Nur erforderlich, wenn ein Fehler in StartOS vorliegt.',
@@ -287,7 +285,6 @@ export default {
286: 'Dashboard',
287: 'dashboard',
288: 'aktionen',
289: 'anleitungen',
290: 'logs',
291: 'über',
292: 'Upload wird gestartet',
@@ -485,7 +482,7 @@ export default {
484: 'Laden Sie die Seite neu. Wenn das nicht funktioniert, beenden Sie Ihren Browser und öffnen Sie ihn erneut, um diese Seite erneut zu besuchen.',
485: 'StartOS-Benutzeroberfläche',
486: 'WiFi',
487: 'Anleitungen',
487: 'Dokumentation',
488: 'spanisch',
489: 'polnisch',
490: 'deutsch',
@@ -496,7 +493,7 @@ export default {
495: 'Validierung',
496: 'in Bearbeitung',
497: 'abgeschlossen',
498: 'Klicken Sie hier, um alle Versionen anzuzeigen',
498: 'StartOS wird gestartet',
499: 'Um loszulegen, besuche den Marktplatz und lade deinen ersten Dienst herunter',
500: 'Marktplatz anzeigen',
501: 'Willkommen bei',
@@ -522,4 +519,13 @@ export default {
521: 'Um das Problem zu beheben, siehe',
522: 'SDK Version',
523: 'Sicherungsbericht',
524: 'Ausgewählte löschen',
525: 'Keine schlüssel',
526: 'Öffentlichen SSH-Schlüssel hinzufügen',
527: 'Standardmäßig kannst du dich per SSH von jedem Gerät aus mit deinem Server verbinden, indem du dein Master-Passwort verwendest. Optional kannst du SSH-öffentliche Schlüssel hinzufügen, um bestimmten Geräten den Zugriff ohne Passworteingabe zu ermöglichen.',
528: 'Quellcode',
529: 'Upstream-Dienst',
530: 'StartOS-Paket',
531: 'Fehler beim Initialisieren des Servers',
532: 'Abgeschlossen',
} satisfies i18n

View File

@@ -273,20 +273,17 @@ export const ENGLISH = {
'Git Hash': 273,
'License': 274,
'Installed From': 275,
'Service Repository': 276,
'Package Repository': 277,
'Marketing Site': 278,
'Support Site': 279,
'Donation Link': 280,
'Marketing': 278,
'Support': 279,
'Donations': 280,
'Standard Actions': 281,
'Rebuild Service': 282, // as in, rebuild a software container
'Rebuilds the service container. Only necessary if there is a bug in StartOS': 283,
'Uninstall': 284,
'Uninstalls this service from StartOS and delete all data permanently.': 285,
'Uninstalls this service from StartOS and deletes all data permanently.': 285,
'Dashboard': 286,
'dashboard': 287,
'actions': 288,
'instructions': 289,
'logs': 290, // as in, "application logs"
'about': 291, // as in, "about this server"
'Starting upload': 292,
@@ -484,7 +481,7 @@ export const ENGLISH = {
'Refresh the page. If refreshing the page does not work, you may need to quit and re-open your browser, then revisit this page.': 484,
'StartOS UI': 485,
'WiFi': 486,
'Instructions': 487,
'Documentation': 487, // as in, a website to view documentation
'spanish': 488,
'polish': 489,
'german': 490,
@@ -495,7 +492,7 @@ export const ENGLISH = {
'Validating': 495,
'in progress': 496,
'complete': 497,
'Click to view all versions': 498,
'Booting StartOS': 498,
'To get started, visit the Marketplace and download your first service': 499,
'View Marketplace': 500,
'Welcome to': 501,
@@ -521,4 +518,13 @@ export const ENGLISH = {
'To resolve the issue, refer to': 521,
'SDK Version': 522,
'Backup Report': 523,
'Delete selected': 524,
'No keys': 525,
'Add SSH Public Key': 526,
'By default, you can SSH into your server from any device using your master password. Optionally add SSH public keys to grant specific devices access without needing to enter a password.': 527,
'Source Code': 528,
'Upstream service': 529, // as in, the URL of the source code for the original software
'StartOS package': 530, // as in, the URL of the source code for the StartOS package
'Error initializing server': 531,
'Finished': 532, // an in, complete
} as const

View File

@@ -274,11 +274,9 @@ export default {
273: 'Hash de Git',
274: 'Licencia',
275: 'Instalado desde',
276: 'Repositorio del servicio',
277: 'Repositorio del paquete',
278: 'Sitio de marketing',
279: 'Sitio de soporte',
280: 'Enlace de donación',
278: 'Marketing',
279: 'Soporte',
280: 'Donaciones',
281: 'Acciones estándar',
282: 'Reconstruir servicio',
283: 'Reconstruye el contenedor del servicio. Solo es necesario si hay un error en StartOS',
@@ -287,7 +285,6 @@ export default {
286: 'Panel de control',
287: 'panel de control',
288: 'acciones',
289: 'instrucciones',
290: 'registros',
291: 'acerca de',
292: 'Iniciando carga',
@@ -485,7 +482,7 @@ export default {
484: 'Actualiza la página. Si actualizar no funciona, puede que necesites cerrar y volver a abrir tu navegador, y luego volver a esta página.',
485: 'Interfaz de StartOS',
486: 'WiFi',
487: 'Instrucciones',
487: 'Documentación',
488: 'español',
489: 'polaco',
490: 'alemán',
@@ -496,7 +493,7 @@ export default {
495: 'Validando',
496: 'en progreso',
497: 'completo',
498: 'Haga clic para ver todas las versiones',
498: 'Iniciando StartOS',
499: 'Para comenzar, visita el Mercado y descarga tu primer servicio',
500: 'Ver Marketplace',
501: 'Bienvenido a',
@@ -522,4 +519,13 @@ export default {
521: 'Para resolver el problema, consulta',
522: 'Versión de SDK',
523: 'Informe de respaldo',
524: 'Eliminar seleccionado',
525: 'Sin llaves',
526: 'Agregar clave pública SSH',
527: 'De forma predeterminada, puedes conectarte por SSH a tu servidor desde cualquier dispositivo usando tu contraseña maestra. Opcionalmente, añade claves públicas SSH para otorgar acceso a dispositivos específicos sin necesidad de ingresar una contraseña.',
528: 'Código fuente',
529: 'Servicio original',
530: 'Paquete StartOS',
531: 'Error al inicializar el servidor',
532: 'Finalizado',
} satisfies i18n

View File

@@ -274,11 +274,9 @@ export default {
273: 'Hash Git',
274: 'Licence',
275: 'Installé depuis',
276: 'Dépôt du service',
277: 'Dépôt du paquet',
278: 'Site marketing',
279: 'Site de support',
280: 'Lien de don',
278: 'Marketing',
279: 'Support',
280: 'Dons',
281: 'Actions standards',
282: 'Reconstruire le service',
283: 'Reconstruit le conteneur du service. Nécessaire uniquement en cas de bug dans StartOS',
@@ -287,7 +285,6 @@ export default {
286: 'Tableau de bord',
287: 'tableau de bord',
288: 'actions',
289: 'instructions',
290: 'journaux',
291: 'à propos',
292: 'Début du téléversement',
@@ -485,7 +482,7 @@ export default {
484: 'Rafraîchissez la page. Si cela ne fonctionne pas, quittez puis rouvrez votre navigateur et revenez sur cette page.',
485: 'Interface de StartOS',
486: 'WiFi',
487: 'Instructions',
487: 'Documentation',
488: 'espagnol',
489: 'polonais',
490: 'allemand',
@@ -496,7 +493,7 @@ export default {
495: 'Validation',
496: 'en cours',
497: 'terminé',
498: 'Cliquez pour voir toutes les versions',
498: 'Démarrage de StartOS',
499: 'Pour commencer, visitez la bibliothèque de services et téléchargez votre premier service',
500: 'Voir la bibliothèque de services',
501: 'Bienvenue sur',
@@ -522,4 +519,13 @@ export default {
521: 'Pour résoudre le problème, consultez',
522: 'Version de SDK',
523: 'Rapport de sauvegarde',
524: 'Supprimer la sélection',
525: 'Pas de clés',
526: 'Ajouter une clé publique SSH',
527: 'Par défaut, vous pouvez accéder à votre serveur en SSH depuis nimporte quel appareil en utilisant votre mot de passe maître. Vous pouvez également ajouter des clés publiques SSH pour accorder laccès à certains appareils sans avoir à saisir de mot de passe.',
528: 'Code source',
529: 'Service en amont',
530: 'Paquet StartOS',
531: "Erreur lors de l'initialisation du serveur",
532: 'Terminé',
} satisfies i18n

View File

@@ -274,11 +274,9 @@ export default {
273: 'Hash Git',
274: 'Licencja',
275: 'Zainstalowano z',
276: 'Repozytorium serwisu',
277: 'Repozytorium pakietu',
278: 'Strona marketingowa',
279: 'Strona wsparcia',
280: 'Link do darowizny',
278: 'Marketingowa',
279: 'Wsparcia',
280: 'Darowizny',
281: 'Standardowe akcje',
282: 'Odbuduj serwis',
283: 'Odbudowuje kontener serwisu. Konieczne tylko w przypadku błędu w StartOS',
@@ -287,7 +285,6 @@ export default {
286: 'Panel',
287: 'panel',
288: 'akcje',
289: 'instrukcje',
290: 'logi',
291: 'informacje',
292: 'Rozpoczynanie przesyłania',
@@ -485,7 +482,7 @@ export default {
484: 'Odśwież stronę. Jeśli odświeżenie strony nie działa, może być konieczne zamknięcie i ponowne otwarcie przeglądarki, a następnie ponowne odwiedzenie tej strony.',
485: 'Przyłącza StartOS',
486: 'WiFi',
487: 'Instrukcje',
487: 'Dokumentacji',
488: 'hiszpański',
489: 'polski',
490: 'niemiecki',
@@ -496,7 +493,7 @@ export default {
495: 'Weryfikowanie',
496: 'w toku',
497: 'zakończono',
498: 'Kliknij, aby zobaczyć wszystkie wersje',
498: 'Uruchamianie StartOS',
499: 'Aby rozpocząć, odwiedź Marketplace i pobierz swoją pierwszą usługę',
500: 'Zobacz Rynek',
501: 'Witamy w',
@@ -522,4 +519,13 @@ export default {
521: 'Aby rozwiązać problem, zapoznaj się z',
522: 'Wersja SDK',
523: 'Raport kopii zapasowej',
524: 'Usuń wybrane',
525: 'Brak kluczy',
526: 'Dodaj klucz publiczny SSH',
527: 'Domyślnie możesz połączyć się z serwerem przez SSH z dowolnego urządzenia, używając hasła głównego. Opcjonalnie dodaj klucze publiczne SSH, aby przyznać dostęp określonym urządzeniom bez potrzeby wpisywania hasła.',
528: 'Kod źródłowy',
529: 'Usługa źródłowa',
530: 'Pakiet StartOS',
531: 'Błąd inicjalizacji serwera',
532: 'Zakończono',
} satisfies i18n

View File

@@ -5,8 +5,8 @@
export * from './classes/http-error'
export * from './classes/rpc-error'
export * from './components/logs-window.component'
export * from './components/initializing.component'
export * from './components/initializing/logs-window.component'
export * from './components/initializing/initializing.component'
export * from './components/ticker.component'
export * from './components/drive.component'
export * from './components/markdown.component'

View File

@@ -1,5 +1,4 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { Inject, Injectable, DOCUMENT } from '@angular/core'
@Injectable({
providedIn: 'root',

View File

@@ -1,5 +1,4 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { Inject, Injectable, DOCUMENT } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import {
firstValueFrom,

View File

@@ -1,19 +1,23 @@
import { StaticClassProvider } from '@angular/core'
import {
bufferTime,
catchError,
defer,
delay,
filter,
map,
Observable,
repeatWhen,
scan,
switchMap,
timer,
} from 'rxjs'
import { FollowLogsReq, FollowLogsRes, Log } from '../types/api'
import { Constructor } from '../types/constructor'
import { convertAnsi } from '../util/convert-ansi'
interface Api {
followServerLogs: (params: FollowLogsReq) => Promise<FollowLogsRes>
initFollowLogs: (params: FollowLogsReq) => Promise<FollowLogsRes>
openWebsocket$: (guid: string) => Observable<Log>
}
@@ -28,12 +32,14 @@ export function provideSetupLogsService(
}
export class SetupLogsService extends Observable<readonly string[]> {
private readonly log$ = defer(() => this.api.followServerLogs({})).pipe(
private readonly log$ = defer(() => this.api.initFollowLogs({})).pipe(
switchMap(({ guid }) => this.api.openWebsocket$(guid)),
bufferTime(500),
filter(logs => !!logs.length),
map(convertAnsi),
scan((logs: readonly string[], log) => [...logs, log], []),
repeatWhen(obs => obs.pipe(delay(500))),
catchError((_, watch$) => timer(500).pipe(switchMap(() => watch$))),
)
constructor(private readonly api: Api) {

View File

@@ -98,9 +98,6 @@ $wide-modal: 900px;
}
body {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
@@ -174,7 +171,7 @@ a {
font-weight: 300;
text-transform: uppercase;
letter-spacing: 0.06rem;
margin-bottom: 1rem;
margin: 0rem 0 1rem 0;
pointer-events: none;
}

View File

@@ -16,11 +16,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
(tuiAlertChange)="onDismiss()"
>
{{ 'New notifications' | i18n }}
<a
tuiLink
routerLink="/portal/notifications"
[queryParams]="{ toast: true }"
>
<a tuiLink routerLink="/notifications" [queryParams]="{ toast: true }">
{{ 'View' | i18n }}
</a>
</ng-template>

View File

@@ -1,30 +1,31 @@
<ng-container *ngIf="!restarted; else refresh">
@if (!restarted) {
<h1 class="title">StartOS - {{ 'Diagnostic Mode' | i18n }}</h1>
<ng-container *ngIf="error">
@if (error) {
<h2 class="subtitle">StartOS {{ 'launch error' | i18n }}:</h2>
<code class="code warning">
<p>{{ error.problem }}</p>
<p *ngIf="error.details">{{ error.details }}</p>
@if (error.details) {
<p>{{ error.details }}</p>
}
</code>
<a tuiButton routerLink="logs">{{ 'View logs' | i18n }}</a>
<h2 class="subtitle">{{ 'Possible solutions' | i18n }}:</h2>
<code class="code"><p>{{ error.solution }}</p></code>
<code class="code">
<p>{{ error.solution }}</p>
</code>
<div class="buttons">
<button tuiButton (click)="restart()">{{ 'Restart server' | i18n }}</button>
<button
*ngIf="error.code === 15 || error.code === 25"
tuiButton
appearance="secondary"
(click)="forgetDrive()"
>
{{ error.code === 15 ? ('Setup current drive' | i18n) : ('Enter recovery mode' | i18n) }}
<button tuiButton (click)="restart()">
{{ 'Restart server' | i18n }}
</button>
@if (error.code === 15 || error.code === 25) {
<button tuiButton appearance="secondary" (click)="forgetDrive()">
{{
error.code === 15
? ('Setup current drive' | i18n)
: ('Enter recovery mode' | i18n)
}}
</button>
}
<button
tuiButton
appearance="secondary-destructive"
@@ -33,13 +34,11 @@
{{ 'Repair drive' | i18n }}
</button>
</div>
</ng-container>
</ng-container>
<ng-template #refresh>
}
} @else {
<h1 class="title">{{ 'Server is restarting' | i18n }}</h1>
<h2 class="subtitle">
{{ 'Wait for the server to restart, then refresh this page.' | i18n }}
</h2>
<button tuiButton (click)="refreshPage()">{{ 'Refresh' | i18n }}</button>
</ng-template>
}

View File

@@ -7,7 +7,7 @@ import { ConfigService } from 'src/app/services/config.service'
@Component({
selector: 'diagnostic-home',
templateUrl: 'home.page.html',
templateUrl: 'home.component.html',
styleUrls: ['home.page.scss'],
standalone: false,
})

View File

@@ -6,7 +6,16 @@ import {
provideSetupLogsService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { catchError, defer, from, map, startWith, switchMap, tap } from 'rxjs'
import {
catchError,
defer,
from,
map,
startWith,
switchMap,
tap,
timer,
} from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { StateService } from 'src/app/services/state.service'
@@ -29,7 +38,7 @@ export default class InitializingPage {
.openWebsocket$<T.FullProgress>(guid, {
closeObserver: {
next: () => {
this.state.syncState()
this.state.retrigger(true)
},
},
})
@@ -38,13 +47,12 @@ export default class InitializingPage {
map(formatProgress),
tap(({ total }) => {
if (total === 1) {
this.state.syncState()
this.state.retrigger(true)
}
}),
catchError((e, caught$) => {
console.error(e)
this.state.syncState()
return caught$
catchError((_, caught$) => {
this.state.retrigger(true)
return timer(500).pipe(switchMap(() => caught$))
}),
),
{ initialValue: { total: 0, message: 'waiting...' } },

View File

@@ -1,97 +1,92 @@
<div
*ngIf="!caTrusted; else trusted"
tuiCardLarge
tuiSurface="floating"
class="card"
>
<tui-icon icon="@tui.lock" [style.font-size.rem]="4" />
<h1>{{ 'Trust your Root CA' | i18n }}</h1>
<p>
{{
'Download and trust your 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.'
| i18n
}}
</p>
<ol>
<li>
<b>{{ 'Bookmark this page' | i18n }}</b>
-
@if (!caTrusted) {
<div tuiCardLarge tuiSurface="floating" class="card">
<tui-icon icon="@tui.lock" [style.font-size.rem]="4" />
<h1>{{ 'Trust your Root CA' | i18n }}</h1>
<p>
{{
'Save this page so you can access it later. You can also find this address in the file downloaded at the end of initial setup.'
'Download and trust your 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.'
| i18n
}}
</li>
<li>
<b>{{ 'Download your Root CA' | i18n }}</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.'
| i18n
}}
<br />
<a
tuiButton
size="s"
iconEnd="@tui.download"
href="/static/local-root-ca.crt"
>
{{ 'Download' | i18n }}
</a>
</li>
<li>
<b>{{ 'Trust your Root CA' | i18n }}</b>
-
{{
'Follow instructions for your OS. By trusting your Root CA, your device can verify the authenticity of encrypted communications with your server.'
| i18n
}}
<br />
<a
tuiButton
docsLink
size="s"
href="/user-manual/trust-ca.html"
iconEnd="@tui.external-link"
>
{{ 'View instructions' | i18n }}
</a>
</li>
<li>
<b>{{ 'Test' | i18n }}</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.'
| i18n
}}
<br />
<button
tuiButton
size="s"
class="refresh"
appearance="positive"
iconEnd="@tui.refresh-cw"
(click)="refresh()"
>
{{ 'Refresh' | i18n }}
</button>
</li>
</ol>
<button
tuiButton
size="s"
appearance="flat-grayscale"
iconEnd="@tui.external-link"
(click)="launchHttps()"
[disabled]="caTrusted"
>
{{ 'Skip' | i18n }}
</button>
<div>
<small>({{ 'not recommended' | i18n }})</small>
</p>
<ol>
<li>
<b>{{ 'Bookmark this page' | i18n }}</b>
-
{{
'Save this page so you can access it later. You can also find this address in the file downloaded at the end of initial setup.'
| i18n
}}
</li>
<li>
<b>{{ 'Download your Root CA' | i18n }}</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.'
| i18n
}}
<br />
<a
tuiButton
size="s"
iconEnd="@tui.download"
href="/static/local-root-ca.crt"
>
{{ 'Download' | i18n }}
</a>
</li>
<li>
<b>{{ 'Trust your Root CA' | i18n }}</b>
-
{{
'Follow instructions for your OS. By trusting your Root CA, your device can verify the authenticity of encrypted communications with your server.'
| i18n
}}
<br />
<a
tuiButton
docsLink
size="s"
href="/user-manual/trust-ca.html"
iconEnd="@tui.external-link"
>
{{ 'View instructions' | i18n }}
</a>
</li>
<li>
<b>{{ 'Test' | i18n }}</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.'
| i18n
}}
<br />
<button
tuiButton
size="s"
class="refresh"
appearance="positive"
iconEnd="@tui.refresh-cw"
(click)="refresh()"
>
{{ 'Refresh' | i18n }}
</button>
</li>
</ol>
<button
tuiButton
size="s"
appearance="flat-grayscale"
iconEnd="@tui.external-link"
(click)="launchHttps()"
[disabled]="caTrusted"
>
{{ 'Skip' | i18n }}
</button>
<div>
<small>({{ 'not recommended' | i18n }})</small>
</div>
</div>
</div>
<ng-template #trusted>
} @else {
<div tuiCardLarge tuiSurface="floating" class="card">
<tui-icon icon="@tui.shield" class="g-positive" [style.font-size.rem]="4" />
<h1>{{ 'Root CA Trusted!' | i18n }}</h1>
@@ -105,4 +100,4 @@
{{ 'Go to login' | i18n }}
</button>
</div>
</ng-template>
}

View File

@@ -1,5 +1,4 @@
import { CommonModule, DOCUMENT } from '@angular/common'
import { Component, inject } from '@angular/core'
import { Component, inject, DOCUMENT } from '@angular/core'
import { DocsLinkDirective, i18nPipe, RELATIVE_URL } from '@start9labs/shared'
import { TuiButton, TuiIcon, TuiSurface } from '@taiga-ui/core'
import { TuiCardLarge } from '@taiga-ui/layout'
@@ -11,7 +10,6 @@ import { ConfigService } from 'src/app/services/config.service'
templateUrl: './ca-wizard.component.html',
styleUrls: ['./ca-wizard.component.scss'],
imports: [
CommonModule,
TuiIcon,
TuiButton,
TuiCardLarge,

View File

@@ -1,11 +1,11 @@
<!-- Local HTTP -->
<ca-wizard *ngIf="config.isLanHttp(); else notLanHttp"></ca-wizard>
<!-- not Local HTTP -->
<ng-template #notLanHttp>
@if (config.isLanHttp()) {
<!-- Local HTTP -->
<ca-wizard />
} @else {
<!-- not Local HTTP -->
<div tuiCardLarge class="card">
<img alt="StartOS Icon" class="logo" src="assets/img/icon.png" />
<h1 class="header">{{'Login to StartOS' | i18n}}</h1>
<h1 class="header">{{ 'Login to StartOS' | i18n }}</h1>
<form (submit)="submit()">
<tui-input-password
tuiTextfieldIconLeft="@tui.key"
@@ -13,10 +13,10 @@
[(ngModel)]="password"
(ngModelChange)="error = null"
>
{{'Password' | i18n}}
{{ 'Password' | i18n }}
</tui-input-password>
<tui-error class="error" [error]="error || null" />
<button tuiButton class="button">{{'Login' | i18n}}</button>
<button tuiButton class="button">{{ 'Login' | i18n }}</button>
</form>
</div>
</ng-template>
}

View File

@@ -1,15 +1,14 @@
import { Router } from '@angular/router'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { Component, Inject, DestroyRef, inject } from '@angular/core'
import { Component, Inject, DestroyRef, inject, DOCUMENT } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { ConfigService } from 'src/app/services/config.service'
import { i18nKey, LoadingService } from '@start9labs/shared'
import { DOCUMENT } from '@angular/common'
@Component({
selector: 'login',
templateUrl: './login.page.html',
templateUrl: './login.component.html',
styleUrls: ['./login.page.scss'],
providers: [],
standalone: false,

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -46,17 +45,17 @@ export interface FormContext<T> {
<form-group [spec]="spec" />
<footer>
<ng-content />
<ng-container *ngFor="let button of buttons; let last = last">
<button
*ngIf="button.handler; else link"
tuiButton
[appearance]="last ? 'primary' : 'flat-grayscale'"
[type]="last ? 'submit' : 'button'"
(click)="onClick(button.handler)"
>
{{ button.text }}
</button>
<ng-template #link>
@for (button of buttons; track $index) {
@if (button.handler) {
<button
tuiButton
[appearance]="$last ? 'primary' : 'flat-grayscale'"
[type]="$last ? 'submit' : 'button'"
(click)="onClick(button.handler)"
>
{{ button.text }}
</button>
} @else {
<a
tuiButton
appearance="flat-grayscale"
@@ -65,8 +64,8 @@ export interface FormContext<T> {
>
{{ button.text }}
</a>
</ng-template>
</ng-container>
}
}
</footer>
</form>
`,
@@ -85,7 +84,6 @@ export interface FormContext<T> {
}
`,
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
TuiValueChanges,

View File

@@ -1,9 +1,8 @@
<div class="label">
{{ spec.name }}
<tui-icon
*ngIf="spec.description || spec.disabled"
[tuiTooltip]="spec | hint"
/>
@if (spec.description || spec.disabled) {
<tui-icon [tuiTooltip]="spec | hint" />
}
<button
tuiLink
type="button"
@@ -14,24 +13,24 @@
+ {{ 'Add' | i18n }}
</button>
</div>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<tui-error [error]="order | tuiFieldError | async" />
<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>
@for (item of array.control.controls; track item) {
@if (spec.spec.type === 'object') {
<form-object
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" />
</form-object>
} @else {
<form-control
class="control"
tuiTextfieldSize="m"
@@ -41,8 +40,8 @@
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
></form-control>
</ng-template>
/>
}
<ng-template #remove>
<button
tuiIconButton
@@ -52,7 +51,7 @@
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt(index)"
(click.stop)="removeAt($index)"
></button>
</ng-template>
</ng-container>
}

View File

@@ -10,18 +10,21 @@
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
@if (spec.required) {
<span>*</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)"
/>
@if (!readOnly && !spec.disabled) {
<input
type="color"
class="color"
tabindex="-1"
[(ngModel)]="value"
(click.stop)="(0)"
/>
}
<tui-icon icon="@tui.paint-bucket" tuiAppearance="icon" class="icon" />
</div>
</ng-template>

View File

@@ -1,40 +1,58 @@
<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' | i18n }}!</p>
<div class="buttons">
<button
tuiButton
type="button"
appearance="secondary"
size="s"
(click)="completeWith(true)"
>
{{ 'Cancel' | i18n }}
</button>
<button
tuiButton
type="button"
appearance="flat-grayscale"
size="s"
(click)="completeWith(false)"
>
{{'Continue' | i18n}}
</button>
</div>
</ng-template>
@switch (spec.type) {
@case ('color') {
<form-color />
}
@case ('datetime') {
<form-datetime />
}
@case ('file') {
<form-file />
}
@case ('multiselect') {
<form-multiselect />
}
@case ('number') {
<form-number />
}
@case ('select') {
<form-select />
}
@case ('text') {
<form-text />
}
@case ('textarea') {
<form-textarea />
}
@case ('toggle') {
<form-toggle />
}
}
<tui-error [error]="order | tuiFieldError | async" />
@if (spec.warning || immutable) {
<ng-template #warning let-completeWith="completeWith">
{{ spec.warning }}
@if (immutable) {
<p>{{ 'This value cannot be changed once set' | i18n }}!</p>
}
<div class="buttons">
<button
tuiButton
type="button"
appearance="secondary"
size="s"
(click)="completeWith(true)"
>
{{ 'Cancel' | i18n }}
</button>
<button
tuiButton
type="button"
appearance="flat-grayscale"
size="s"
(click)="completeWith(false)"
>
{{ 'Continue' | i18n }}
</button>
</div>
</ng-template>
}

View File

@@ -1,43 +1,54 @@
<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 [tuiHintContent]="spec.description">
@switch (spec.inputmode) {
@case ('time') {
<tui-input-time
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[ngModel]="getTime(value)"
(ngModelChange)="value = $event?.toString() || null"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
@if (spec.required) {
<span>*</span>
}
</tui-input-time>
}
@case ('date') {
<tui-input-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 }}
@if (spec.required) {
<span>*</span>
}
</tui-input-date>
}
@case ('datetime-local') {
<tui-input-date-time
[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 }}
@if (spec.required) {
<span>*</span>
}
</tui-input-date-time>
}
}
</ng-container>

View File

@@ -1,29 +1,30 @@
<ng-container
*ngFor="let entry of spec | keyvalue: asIsOrder | filterHidden"
[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>
@for (entry of spec | keyvalue: asIsOrder | filterHidden; track entry) {
<ng-container [tuiTextfieldCleaner]="true">
@switch (entry.value.type) {
@case ('object') {
<form-object
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
/>
}
@case ('union') {
<form-union
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
/>
}
@case ('list') {
<form-array [formArrayName]="entry.key" [spec]="$any(entry.value)" />
}
@default {
<form-control
class="g-form-control"
[formControlName]="entry.key"
[spec]="entry.value"
/>
}
}
</ng-container>
}

View File

@@ -6,7 +6,7 @@
[pseudoInvalid]="invalid"
[tuiNumberFormat]="{
precision: spec.integer ? 0 : Infinity,
decimalMode: 'not-zero'
decimalMode: 'not-zero',
}"
[min]="spec.min ?? -Infinity"
[max]="spec.max ?? Infinity"
@@ -15,6 +15,8 @@
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
@if (spec.required) {
<span>*</span>
}
<input tuiTextfieldLegacy [placeholder]="spec.placeholder || ''" />
</tui-input-number>

View File

@@ -8,7 +8,9 @@
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
@if (spec.required) {
<span>*</span>
}
<input
tuiTextfieldLegacy
[class.masked]="spec.masked && masked"
@@ -19,26 +21,28 @@
/>
</tui-input>
<ng-template #toggle>
<button
*ngIf="spec.generate"
tuiIconButton
type="button"
appearance="icon"
title="Generate"
size="xs"
class="button"
iconStart="@tui.refresh-ccw"
(click)="generate()"
></button>
<button
*ngIf="spec.masked"
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconStart]="masked ? '@tui.eye' : '@tui.eye-off'"
(click)="masked = !masked"
></button>
@if (spec.generate) {
<button
tuiIconButton
type="button"
appearance="icon"
title="Generate"
size="xs"
class="button"
iconStart="@tui.refresh-ccw"
(click)="generate()"
></button>
}
@if (spec.masked) {
<button
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconStart]="masked ? '@tui.eye' : '@tui.eye-off'"
(click)="masked = !masked"
></button>
}
</ng-template>

View File

@@ -10,7 +10,9 @@
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
@if (spec.required) {
<span>*</span>
}
<textarea
tuiTextfieldLegacy
[placeholder]="spec.placeholder || ''"

View File

@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { CopyService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiFade } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
@@ -46,6 +47,20 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
{{ 'Copy' | i18n }}
</button>
</div>
<div tuiCell>
<div tuiTitle>
<strong>Public Key</strong>
<div tuiSubtitle tuiFade>{{ getPubkey(server) }}</div>
</div>
<button
tuiIconButton
appearance="icon"
iconStart="@tui.copy"
(click)="copyService.copy(getPubkey(server))"
>
{{ 'Copy' | i18n }}
</button>
</div>
}
`,
styles: '[tuiCell] { padding-inline: 0; white-space: nowrap }',
@@ -58,6 +73,10 @@ export class AboutComponent {
readonly server = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo'),
)
getPubkey(server: T.ServerInfo) {
return `${server.pubkey} startos@${server.hostname}`
}
}
export const ABOUT = new PolymorpheusComponent(AboutComponent)

View File

@@ -72,7 +72,7 @@ import { ABOUT } from './about.component'
<a
tuiOption
iconStart="@tui.settings"
routerLink="/portal/system"
routerLink="/system"
(click)="open = false"
>
{{ 'System Settings' | i18n }}

View File

@@ -22,9 +22,9 @@ import { getMenu } from 'src/app/utils/system-utilities'
class="link"
routerLinkActive="link_active"
tuiHintDirection="bottom"
[tuiHintShowDelay]="750"
[routerLink]="item.routerLink"
[class.link_system]="item.routerLink === '/portal/system'"
[tuiHintShowDelay]="250"
[routerLink]="['/', item.routerLink]"
[class.link_system]="item.routerLink === 'system'"
[tuiHint]="rla.isActive ? '' : (item.name | i18n)"
>
<tui-badged-content

View File

@@ -3,6 +3,7 @@ import {
Component,
inject,
input,
DOCUMENT,
} from '@angular/core'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
@@ -15,7 +16,6 @@ import {
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { InterfaceComponent } from './interface.component'
import { DOCUMENT } from '@angular/common'
@Component({
selector: 'td[actions]',

View File

@@ -74,7 +74,7 @@ export class LogsPipe implements PipeTransform {
this.logs.status$.next(v ? 'reconnecting' : 'disconnected'),
),
filter(Boolean),
delay(1000),
delay(1000), // @TODO Alex why delay here?
take(1),
ignoreElements(),
),

View File

@@ -14,7 +14,7 @@ import { TuiBadgeNotification } from '@taiga-ui/kit'
import { BadgeService } from 'src/app/services/badge.service'
import { getMenu } from 'src/app/utils/system-utilities'
const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
const FILTER = ['/services', '/system', '/marketplace']
@Component({
selector: 'app-tabs',
@@ -23,7 +23,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
<a
tuiTabBarItem
icon="@tui.layout-grid"
routerLink="/portal/services"
routerLink="/services"
routerLinkActive
(isActiveChange)="update()"
>
@@ -32,7 +32,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
<a
tuiTabBarItem
icon="@tui.shopping-cart"
routerLink="/portal/marketplace"
routerLink="/marketplace"
routerLinkActive
(isActiveChange)="update()"
>
@@ -41,7 +41,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
<a
tuiTabBarItem
icon="@tui.settings"
routerLink="/portal/system"
routerLink="/system"
routerLinkActive
[badge]="badge()"
(isActiveChange)="update()"
@@ -60,7 +60,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
<a
class="item"
routerLinkActive="item_active"
[routerLink]="item.routerLink"
[routerLink]="['/', item.routerLink]"
(click)="observer.complete()"
>
<tui-icon [icon]="item.icon" />
@@ -124,7 +124,7 @@ export class TabsComponent {
index = 3
readonly menu = getMenu().filter(item => !FILTER.includes(item.routerLink))
readonly badge = toSignal(inject(BadgeService).getCount('/portal/system'), {
readonly badge = toSignal(inject(BadgeService).getCount('system'), {
initialValue: 0,
})

View File

@@ -9,19 +9,22 @@ import { TuiNotification } from '@taiga-ui/core'
import { getValueByPointer, Operation } from 'fast-json-patch'
import { i18nPipe, isObject } from '@start9labs/shared'
import { tuiIsNumber } from '@taiga-ui/cdk'
import { CommonModule } from '@angular/common'
@Component({
selector: 'task-info',
template: `
<tui-notification *ngIf="diff.length">
{{ 'The following modifications were made' | i18n }}:
<ul>
<li *ngFor="let d of diff" [innerHTML]="d"></li>
</ul>
</tui-notification>
@if (diff.length) {
<tui-notification>
{{ 'The following modifications were made' | i18n }}:
<ul>
@for (d of diff; track d) {
<li [innerHTML]="d"></li>
}
</ul>
</tui-notification>
}
`,
imports: [CommonModule, TuiNotification, i18nPipe],
imports: [TuiNotification, i18nPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: `
tui-notification {

View File

@@ -25,50 +25,50 @@ const ROUTES: Routes = [
// title: titleResolver,
// path: 'backups',
// loadComponent: () => import('./routes/backups/backups.component'),
// data: toNavigationItem('/portal/backups'),
// data: toNavigationItem('backups'),
// },
{
title: titleResolver,
path: 'logs',
loadChildren: () => import('./routes/logs/logs.routes'),
data: toNavigationItem('/portal/logs'),
data: toNavigationItem('logs'),
},
{
title: titleResolver,
path: 'marketplace',
loadChildren: () => import('./routes/marketplace/marketplace.routes'),
data: toNavigationItem('/portal/marketplace'),
data: toNavigationItem('marketplace'),
},
{
title: titleResolver,
path: 'system',
loadChildren: () => import('./routes/system/system.routes'),
data: toNavigationItem('/portal/system'),
data: toNavigationItem('system'),
},
{
title: titleResolver,
path: 'notifications',
loadComponent: () =>
import('./routes/notifications/notifications.component'),
data: toNavigationItem('/portal/notifications'),
data: toNavigationItem('notifications'),
},
{
title: titleResolver,
path: 'sideload',
loadComponent: () => import('./routes/sideload/sideload.component'),
data: toNavigationItem('/portal/sideload'),
data: toNavigationItem('sideload'),
},
{
title: titleResolver,
path: 'updates',
loadComponent: () => import('./routes/updates/updates.component'),
data: toNavigationItem('/portal/updates'),
data: toNavigationItem('updates'),
},
{
title: titleResolver,
path: 'metrics',
loadComponent: () => import('./routes/metrics/metrics.component'),
data: toNavigationItem('/portal/metrics'),
data: toNavigationItem('metrics'),
},
],
},

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
@@ -58,19 +57,21 @@ import { TARGET, TARGET_CREATE } from './target.component'
Schedule
<input tuiTextfieldLegacy placeholder="* * * * *" />
</tui-input>
<div *ngIf="job.cron | toHumanCron as human" [style.color]="human.color">
{{ human.message }}
</div>
<div *ngIf="!job.job.id" class="g-toggle">
Also Execute Now
<input
tuiSwitch
type="checkbox"
name="now"
[showIcons]="false"
[(ngModel)]="job.now"
/>
</div>
@if (job.cron | toHumanCron; as human) {
<div [style.color]="human.color">{{ human.message }}</div>
}
@if (!job.job.id) {
<div class="g-toggle">
Also Execute Now
<input
tuiSwitch
type="checkbox"
name="now"
[showIcons]="false"
[(ngModel)]="job.now"
/>
</div>
}
<button
tuiButton
class="submit"
@@ -96,7 +97,6 @@ import { TARGET, TARGET_CREATE } from './target.component'
}
`,
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiInputNumberModule,

View File

@@ -17,7 +17,7 @@ import { RecoverOption } from '../types/recover-option'
@Component({
template: `
<ng-container *ngIf="packageData$ | toOptions: backups | async as options">
@if (packageData$ | toOptions: backups | async; as options) {
<div
tuiGroup
orientation="vertical"
@@ -30,12 +30,9 @@ import { RecoverOption } from '../types/recover-option'
<strong>{{ option.title }}</strong>
<div>Version {{ option.version }}</div>
<div>Backup made: {{ option.timestamp | date: 'medium' }}</div>
<div
*ngIf="option | tuiMapper: toMessage as message"
[style.color]="message.color"
>
{{ message.text }}
</div>
@if (option | tuiMapper: toMessage; as message) {
<div [style.color]="message.color">{{ message.text }}</div>
}
</div>
<input
type="checkbox"
@@ -46,7 +43,6 @@ import { RecoverOption } from '../types/recover-option'
</label>
}
</div>
<footer class="g-buttons">
<button
tuiButton
@@ -56,7 +52,7 @@ import { RecoverOption } from '../types/recover-option'
Restore Selected
</button>
</footer>
</ng-container>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit, signal } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiButton, TuiLink, TuiNotification } from '@taiga-ui/core'
@@ -64,7 +63,6 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api'
></table>
`,
imports: [
CommonModule,
TuiNotification,
TuiButton,
BackupsPhysicalComponent,

View File

@@ -68,7 +68,7 @@ export class BackupsRestoreService {
),
)
.subscribe(() => {
this.router.navigate(['/portal/services'])
this.router.navigate(['services'])
})
}

View File

@@ -1,199 +0,0 @@
import { KeyValuePipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiAppearance, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCardMedium } from '@taiga-ui/layout'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TitleDirective } from 'src/app/services/title.service'
interface Log {
title: i18nKey
subtitle: i18nKey
icon: string
follow: (params: RR.FollowServerLogsReq) => Promise<RR.FollowServerLogsRes>
fetch: (params: RR.GetServerLogsReq) => Promise<RR.GetServerLogsRes>
}
@Component({
template: `
<ng-container *title>
@if (current(); as key) {
<button
tuiIconButton
iconStart="@tui.arrow-left"
(click)="current.set(null)"
>
{{ 'Back' | i18n }}
</button>
{{ logs[key]?.title | i18n }}
} @else {
{{ 'Logs' | i18n }}
}
</ng-container>
@if (current(); as key) {
<header tuiTitle>
<strong class="title">
<button
tuiIconButton
appearance="secondary-grayscale"
iconStart="@tui.x"
size="s"
class="close"
(click)="current.set(null)"
>
{{ 'Close' | i18n }}
</button>
{{ logs[key]?.title | i18n }}
</strong>
<p tuiSubtitle>{{ logs[key]?.subtitle | i18n }}</p>
</header>
@for (log of logs | keyvalue; track $index) {
@if (log.key === current()) {
<logs
[context]="log.key"
[followLogs]="log.value.follow"
[fetchLogs]="log.value.fetch"
/>
}
}
} @else {
@for (log of logs | keyvalue; track $index) {
<button
tuiCardMedium
tuiAppearance="neutral"
(click)="current.set(log.key)"
>
<tui-icon [icon]="log.value.icon" />
<span tuiTitle>
<strong>{{ log.value.title | i18n }}</strong>
<span tuiSubtitle>{{ log.value.subtitle | i18n }}</span>
</span>
<tui-icon icon="@tui.chevron-right" />
</button>
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'g-page' },
styles: `
:host {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
}
header {
width: 100%;
padding: 0 1rem;
}
strong {
font-weight: bold;
}
logs {
height: calc(100% - 4rem);
width: 100%;
}
.close {
position: absolute;
right: 0;
border-radius: 100%;
}
button::before {
margin: 0 -0.25rem 0 -0.375rem;
--tui-icon-size: 1.5rem;
}
[tuiCardMedium] {
height: 14rem;
width: 14rem;
cursor: pointer;
box-shadow:
inset 0 0 0 1px var(--tui-background-neutral-1),
var(--tui-shadow-small);
[tuiSubtitle] {
color: var(--tui-text-secondary);
}
tui-icon:last-child {
align-self: flex-end;
}
}
:host-context(tui-root._mobile) {
flex-direction: column;
justify-content: flex-start;
header {
padding: 0;
}
.title {
display: none;
}
logs {
height: calc(100% - 2rem);
}
[tuiCardMedium] {
width: 100%;
height: auto;
gap: 1rem;
}
}
`,
imports: [
LogsComponent,
TitleDirective,
KeyValuePipe,
TuiTitle,
TuiCardMedium,
TuiIcon,
TuiAppearance,
TuiButton,
i18nPipe,
],
})
export default class SystemLogsComponent {
private readonly api = inject(ApiService)
readonly current = signal<string | null>(null)
readonly logs: Record<string, Log> = {
os: {
title: 'OS Logs',
subtitle: 'Raw, unfiltered operating system logs',
icon: '@tui.square-dashed-bottom-code',
follow: params => this.api.followServerLogs(params),
fetch: params => this.api.getServerLogs(params),
},
kernel: {
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
follow: params => this.api.followKernelLogs(params),
fetch: params => this.api.getKernelLogs(params),
},
tor: {
title: 'Tor Logs',
subtitle: 'Diagnostic logs for the Tor daemon on StartOS',
icon: '@tui.globe',
follow: params => this.api.followTorLogs(params),
fetch: params => this.api.getTorLogs(params),
},
}
}

View File

@@ -2,10 +2,13 @@ import { CommonModule, TitleCasePipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { toObservable, toSignal } from '@angular/core/rxjs-interop'
import { Router } from '@angular/router'
import { MarketplacePkg } from '@start9labs/marketplace'
import {
ErrorService,
Exver,
@@ -17,29 +20,29 @@ import {
} from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom } from 'rxjs'
import { firstValueFrom, switchMap } from 'rxjs'
import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { dryUpdate } from 'src/app/utils/dry-update'
import { getAllPackages, getManifest } from 'src/app/utils/get-package-data'
import { hasCurrentDeps } from 'src/app/utils/has-deps'
import { MarketplacePreviewComponent } from '../modals/preview.component'
import { MarketplaceAlertsService } from '../services/alerts.service'
type KEYS = 'id' | 'version' | 'alerts' | 'flavor'
@Component({
selector: 'marketplace-controls',
template: `
@if (localPkg(); as local) {
@if (sameFlavor() && localPkg(); as local) {
@if (local.stateInfo.state === 'installed') {
@switch ((local | toManifest).version | compareExver: version()) {
@switch ((local | toManifest).version | compareExver: pkg().version) {
@case (1) {
<button
tuiButton
size="m"
type="button"
appearance="warning"
(click)="tryInstall()"
@@ -50,6 +53,7 @@ import { MarketplaceAlertsService } from '../services/alerts.service'
@case (-1) {
<button
tuiButton
size="m"
type="button"
appearance="primary"
(click)="tryInstall()"
@@ -60,6 +64,7 @@ import { MarketplaceAlertsService } from '../services/alerts.service'
@case (0) {
<button
tuiButton
size="m"
type="button"
appearance="secondary-grayscale"
(click)="tryInstall()"
@@ -71,27 +76,34 @@ import { MarketplaceAlertsService } from '../services/alerts.service'
}
<button
tuiButton
size="m"
type="button"
appearance="secondary-grayscale"
(click)="showService()"
>
{{
('View' | i18n) +
' ' +
($any(local.stateInfo.state | titlecase) | i18n)
}}
{{ 'View' | i18n }}
{{ $any(local.stateInfo.state | titlecase) | i18n }}
</button>
} @else {
<button
tuiButton
size="m"
type="button"
appearance="primary"
(click)="tryInstall()"
>
{{ localFlavor() ? ('Switch' | i18n) : ('Install' | i18n) }}
{{ (sameFlavor() ? 'Install' : 'Switch') | i18n }}
</button>
}
`,
styles: `
:host {
display: flex;
justify-content: flex-start;
gap: 0.5rem;
height: 4.5rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
@@ -109,27 +121,35 @@ export class MarketplaceControlsComponent {
private readonly loader = inject(LoadingService)
private readonly exver = inject(Exver)
private readonly router = inject(Router)
private readonly marketplaceService = inject(MarketplaceService)
private readonly marketplace = inject(MarketplaceService)
private readonly api = inject(ApiService)
private readonly preview = inject(MarketplacePreviewComponent)
version = input.required<string>()
installAlert = input.required<string | null>()
localPkg = input.required<PackageDataEntry | null>()
localFlavor = input.required<boolean>()
readonly pkg = input.required<Pick<MarketplacePkg, KEYS>>()
// only present if side loading
file = input<File>()
readonly file = input<File>()
readonly localPkg = toSignal(
toObservable(this.pkg).pipe(
switchMap(({ id }) => this.patch.watch$('packageData', id)),
),
)
readonly sameFlavor = computed(
(pkg = this.localPkg()) =>
!pkg ||
this.exver.getFlavor(getManifest(pkg).version) === this.pkg().flavor,
)
async tryInstall() {
const localPkg = this.localPkg()
const currentUrl = this.file()
? null
: await firstValueFrom(this.marketplaceService.currentRegistryUrl$)
: await firstValueFrom(this.marketplace.currentRegistryUrl$)
const originalUrl = localPkg?.registry || null
if (!localPkg) {
if (await this.alerts.alertInstall(this.installAlert() || '')) {
if (await this.alerts.alertInstall(this.pkg().alerts.install || '')) {
this.installOrUpload(currentUrl)
}
return
@@ -143,11 +163,11 @@ export class MarketplaceControlsComponent {
return
}
const localManifest = getManifest(localPkg)
const { id, version } = getManifest(localPkg)
if (
hasCurrentDeps(localManifest.id, await getAllPackages(this.patch)) &&
this.exver.compareExver(localManifest.version, this.version()) !== 0
hasCurrentDeps(id, await getAllPackages(this.patch)) &&
this.exver.compareExver(version, this.pkg().version) !== 0
) {
this.dryInstall(currentUrl)
} else {
@@ -156,16 +176,13 @@ export class MarketplaceControlsComponent {
}
async showService() {
this.router.navigate(['/portal/services', this.preview.pkgId])
this.router.navigate(['services', this.pkg().id])
}
private async dryInstall(url: string | null) {
const id = this.preview.pkgId
const breakages = dryUpdate(
{ id, version: this.version() },
await getAllPackages(this.patch),
this.exver,
)
const { id, version } = this.pkg()
const packages = await getAllPackages(this.patch)
const breakages = dryUpdate({ id, version }, packages, this.exver)
if (
isEmptyObject(breakages) ||
@@ -178,7 +195,7 @@ export class MarketplaceControlsComponent {
private async installOrUpload(url: string | null) {
if (this.file()) {
await this.upload()
this.router.navigate(['/portal', 'services'])
this.router.navigate(['services'])
} else if (url) {
await this.install(url)
}
@@ -186,10 +203,10 @@ export class MarketplaceControlsComponent {
private async install(url: string) {
const loader = this.loader.open('Beginning install').subscribe()
const id = this.preview.pkgId
const { id, version } = this.pkg()
try {
await this.marketplaceService.installPackage(id, this.version(), url)
await this.marketplace.installPackage(id, version, url)
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -6,30 +6,15 @@ import {
inject,
input,
} from '@angular/core'
import { toObservable, toSignal } from '@angular/core/rxjs-interop'
import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute, Router } from '@angular/router'
import { ItemModule, MarketplacePkg } from '@start9labs/marketplace'
import { Exver } from '@start9labs/shared'
import { TuiAutoFocus } from '@taiga-ui/cdk'
import { TuiButton, TuiDropdownService, TuiPopup } from '@taiga-ui/core'
import { TuiDrawer } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import {
debounceTime,
filter,
map,
Observable,
shareReplay,
switchMap,
} from 'rxjs'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { debounceTime } from 'rxjs'
import { MarketplacePreviewComponent } from '../modals/preview.component'
import { MarketplaceSidebarService } from '../services/sidebar.service'
import { MarketplaceControlsComponent } from './controls.component'
@Component({
selector: 'marketplace-tile',
@@ -40,7 +25,7 @@ import { MarketplaceControlsComponent } from './controls.component'
[overlay]="true"
(click.self)="toggle(false)"
>
<marketplace-preview [pkgId]="pkg().id" class="preview-wrapper">
<marketplace-preview [pkgId]="pkg().id">
<button
tuiAutoFocus
slot="close"
@@ -53,19 +38,18 @@ import { MarketplaceControlsComponent } from './controls.component'
[tuiAppearanceFocus]="false"
(click)="toggle(false)"
></button>
<marketplace-controls
slot="controls"
class="controls-wrapper"
[version]="pkg().version"
[installAlert]="pkg().alerts.install"
[localPkg]="local$ | async"
[localFlavor]="!!(flavor$ | async)"
/>
</marketplace-preview>
</tui-drawer>
</marketplace-item>
`,
styles: `
@keyframes animateIn {
from {
opacity: 0;
transform: scale(0.6) translateY(-20px);
}
}
:host {
cursor: pointer;
animation: animateIn 400ms calc(var(--animation-order) * 200ms) both;
@@ -77,28 +61,7 @@ import { MarketplaceControlsComponent } from './controls.component'
border-radius: 0;
}
@keyframes animateIn {
from {
opacity: 0;
transform: scale(0.6) translateY(-20px);
}
to {
opacity: 1;
}
}
.preview-wrapper {
overflow-y: auto;
height: 100%;
max-width: 100%;
@media (min-width: 768px) {
max-width: 30rem;
}
}
.close-button {
button {
place-self: end;
margin-bottom: 0;
@@ -106,13 +69,6 @@ import { MarketplaceControlsComponent } from './controls.component'
margin-bottom: 2rem;
}
}
.controls-wrapper {
display: flex;
justify-content: flex-start;
gap: 0.5rem;
height: 4.5rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
@@ -128,13 +84,10 @@ import { MarketplaceControlsComponent } from './controls.component'
TuiButton,
TuiPopup,
TuiDrawer,
MarketplaceControlsComponent,
MarketplacePreviewComponent,
],
})
export class MarketplaceTileComponent {
private readonly exver = inject(Exver)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly router = inject(Router)
private readonly params = toSignal(
inject(ActivatedRoute).queryParamMap.pipe(debounceTime(100)),
@@ -147,24 +100,6 @@ export class MarketplaceTileComponent {
this.params()?.get('flavor') === this.pkg()?.flavor,
)
readonly local$: Observable<PackageDataEntry | null> = toObservable(
this.pkg,
).pipe(
switchMap(({ id, flavor }) =>
this.patch.watch$('packageData', id).pipe(
filter(Boolean),
map(pkg =>
this.exver.getFlavor(getManifest(pkg).version) === flavor
? pkg
: null,
),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
)
readonly flavor$ = this.local$.pipe(map(pkg => !pkg))
toggle(open: boolean) {
this.router.navigate([], {
queryParams: {

View File

@@ -9,10 +9,8 @@ import {
} from '@start9labs/marketplace'
import { defaultRegistries, i18nPipe } from '@start9labs/shared'
import { TuiScrollbar } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { tap, withLatestFrom } from 'rxjs'
import { tap } from 'rxjs'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { MarketplaceMenuComponent } from './components/menu.component'
import { MarketplaceNotificationComponent } from './components/notification.component'

View File

@@ -3,29 +3,26 @@ import {
ChangeDetectionStrategy,
Component,
inject,
input,
Input,
TemplateRef,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import {
AboutModule,
AdditionalModule,
FlavorsComponent,
MarketplaceAdditionalItemComponent,
MarketplaceLinksComponent,
MarketplaceFlavorsComponent,
MarketplaceAboutComponent,
MarketplaceDependenciesComponent,
MarketplacePackageHeroComponent,
MarketplacePkg,
MarketplaceVersionsComponent,
MarketplaceReleaseNotesComponent,
} from '@start9labs/marketplace'
import {
DialogService,
Exver,
i18nPipe,
MARKDOWN,
SharedPipesModule,
} from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiLoader } from '@taiga-ui/core'
import { TuiRadioList } from '@taiga-ui/kit'
import { TuiLoader } from '@taiga-ui/core'
import {
BehaviorSubject,
combineLatest,
@@ -34,7 +31,9 @@ import {
startWith,
switchMap,
} from 'rxjs'
import { shareReplay, take, tap } from 'rxjs/operators'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { MarketplaceControlsComponent } from '../components/controls.component'
@Component({
selector: 'marketplace-preview',
@@ -43,50 +42,25 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
<ng-content select="[slot=close]" />
@if (pkg$ | async; as pkg) {
<marketplace-package-hero [pkg]="pkg">
<ng-content select="[slot=controls]" />
<marketplace-controls [pkg]="pkg" />
</marketplace-package-hero>
<div class="inner-container">
<marketplace-about [pkg]="pkg" (static)="onStatic()" />
<marketplace-release-notes [pkg]="pkg" />
@if (flavors$ | async; as flavors) {
<marketplace-flavors [pkgs]="flavors" />
}
<marketplace-about [pkg]="pkg" />
@if (!(pkg.dependencyMetadata | empty)) {
<marketplace-dependencies [pkg]="pkg" (open)="open($event)" />
}
<marketplace-additional [pkg]="pkg" (static)="onStatic($event)">
@if (versions$ | async; as versions) {
<marketplace-additional-item
(click)="selectVersion(pkg, version)"
[data]="('Click to view all versions' | i18n) || ''"
icon="@tui.chevron-right"
label="All versions"
class="versions"
/>
<ng-template
#version
let-data="data"
let-completeWith="completeWith"
>
<tui-radio-list [items]="versions" [(ngModel)]="data.version" />
<footer class="buttons">
<button
tuiButton
appearance="secondary"
(click)="completeWith(null)"
>
{{ 'Cancel' | i18n }}
</button>
<button
tuiButton
appearance="secondary"
(click)="completeWith(data.version)"
>
{{ 'Ok' | i18n }}
</button>
</footer>
</ng-template>
}
</marketplace-additional>
<marketplace-links [pkg]="pkg" />
@if (versions$ | async; as versions) {
<marketplace-versions
[version]="pkg.version"
[versions]="versions"
(onVersion)="selectedVersion$.next($event)"
/>
}
</div>
} @else {
<tui-loader textContent="Loading" [style.height.%]="100" />
@@ -96,6 +70,13 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
styles: `
:host {
pointer-events: auto;
overflow-y: auto;
height: 100%;
max-width: 100%;
@media (min-width: 768px) {
max-width: 30rem;
}
}
.outer-container {
@@ -125,20 +106,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
}
}
.versions {
border: 0;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: rgb(113 113 122);
border-style: solid;
cursor: pointer;
::ng-deep label {
cursor: pointer;
}
}
marketplace-additional {
marketplace-versions {
padding-bottom: 2rem;
}
`,
@@ -147,37 +115,39 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
CommonModule,
MarketplacePackageHeroComponent,
MarketplaceDependenciesComponent,
MarketplaceAdditionalItemComponent,
TuiButton,
AdditionalModule,
AboutModule,
SharedPipesModule,
FormsModule,
TuiRadioList,
TuiLoader,
FlavorsComponent,
i18nPipe,
MarketplaceLinksComponent,
MarketplaceFlavorsComponent,
MarketplaceAboutComponent,
MarketplaceControlsComponent,
MarketplaceVersionsComponent,
MarketplaceReleaseNotesComponent,
],
})
export class MarketplacePreviewComponent {
@Input({ required: true })
pkgId!: string
private readonly dialog = inject(DialogService)
private readonly exver = inject(Exver)
private readonly router = inject(Router)
private readonly marketplaceService = inject(MarketplaceService)
readonly pkgId = input.required<string>()
private readonly flavor$ = this.router.routerState.root.queryParamMap.pipe(
map(paramMap => paramMap.get('flavor')),
take(1),
)
readonly version$ = new BehaviorSubject<string | null>(null)
readonly pkg$ = combineLatest([this.version$, this.flavor$]).pipe(
readonly selectedVersion$ = new BehaviorSubject<string | null>(null)
readonly pkg$ = combineLatest([this.selectedVersion$, this.flavor$]).pipe(
tap(console.error),
switchMap(([version, flavor]) =>
this.marketplaceService
.getPackage$(this.pkgId, version, flavor)
.getPackage$(this.pkgId(), version, flavor)
.pipe(startWith(null)),
),
shareReplay({ bufferSize: 1, refCount: false }),
)
readonly flavors$ = this.flavor$.pipe(
@@ -185,7 +155,7 @@ export class MarketplacePreviewComponent {
this.marketplaceService.currentRegistry$.pipe(
map(({ packages }) =>
packages.filter(
({ id, flavor }) => id === this.pkgId && flavor !== current,
({ id, flavor }) => id === this.pkgId() && flavor !== current,
),
),
filter(p => p.length > 0),
@@ -211,34 +181,18 @@ export class MarketplacePreviewComponent {
this.router.navigate([], { queryParams: { id } })
}
onStatic(asset: 'license' | 'instructions') {
const label = asset === 'license' ? 'License' : 'Instructions'
onStatic() {
const content = this.pkg$.pipe(
filter(Boolean),
switchMap(pkg =>
this.marketplaceService.fetchStatic$(
pkg,
asset === 'license' ? 'LICENSE.md' : 'instructions.md',
),
),
switchMap(pkg => this.marketplaceService.fetchStatic$(pkg)),
)
this.dialog
.openComponent(MARKDOWN, { label, size: 'l', data: { content } })
.openComponent(MARKDOWN, {
label: 'License',
size: 'l',
data: { content },
})
.subscribe()
}
selectVersion(
{ version }: MarketplacePkg,
template: TemplateRef<TuiDialogContext>,
) {
this.dialog
.openComponent<string>(template, {
label: 'All versions',
size: 's',
data: { version },
})
.pipe(filter(Boolean))
.subscribe(selected => this.version$.next(selected))
}
}

View File

@@ -42,7 +42,7 @@ export class MarketplaceAlertsService {
async alertBreakages(breakages: string[]): Promise<boolean> {
let content =
`${this.i18n.transform('As a result of this update, the following services will no longer work properly and may crash')}:<ul>'` as i18nKey
`${this.i18n.transform('As a result of this update, the following services will no longer work properly and may crash')}:<ul>` as i18nKey
const bullets = breakages.map(title => `<li><b>${title}</b></li>`)
content = `${content}${bullets.join('')}</ul>` as i18nKey

View File

@@ -7,7 +7,6 @@ import {
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { map } from 'rxjs'
import { ControlsService } from 'src/app/services/controls.service'
@@ -41,11 +40,11 @@ import { getManifest } from 'src/app/utils/get-package-data'
}
@if (status() === 'stopped') {
@let unmet = hasUnmet() | async;
<button
*tuiLet="hasUnmet() | async as hasUnmet"
tuiButton
iconStart="@tui.play"
(click)="controls.start(manifest(), !!hasUnmet)"
(click)="controls.start(manifest(), !!unmet)"
>
{{ 'Start' | i18n }}
</button>
@@ -83,7 +82,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiLet, AsyncPipe],
imports: [TuiButton, i18nPipe, AsyncPipe],
})
export class ServiceControlsComponent {
private readonly errors = inject(DepErrorService)

View File

@@ -16,7 +16,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@for (d of pkg.currentDependencies | keyvalue; track $index) {
<a
tuiCell
[routerLink]="services[d.key] ? ['..', d.key] : ['/portal/marketplace']"
[routerLink]="services[d.key] ? ['..', d.key] : ['/marketplace']"
[queryParams]="services[d.key] ? {} : { id: d.key }"
[class.error]="getError(d.key)"
>

View File

@@ -1,9 +1,9 @@
import { DOCUMENT } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
DOCUMENT,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk'

View File

@@ -6,7 +6,6 @@ import {
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiProgress } from '@taiga-ui/kit'
import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
@@ -34,7 +33,8 @@ import { getManifest } from 'src/app/utils/get-package-data'
phase of pkg.stateInfo.installingInfo?.progress?.phases;
track $index
) {
<div *tuiLet="phase.progress | installingProgress as percent">
@let percent = phase.progress | installingProgress;
<div>
{{ $any(phase.name) | i18n }}:
@if (phase.progress === null) {
<span>{{ 'waiting' | i18n }}</span>
@@ -76,7 +76,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiProgress, TuiLet, InstallingProgressPipe, i18nPipe, TuiButton],
imports: [TuiProgress, InstallingProgressPipe, i18nPipe, TuiButton],
})
export class ServiceInstallProgressComponent {
@Input({ required: true })

View File

@@ -6,7 +6,7 @@ import {
inject,
input,
} from '@angular/core'
import { TuiLet } from '@taiga-ui/cdk'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core'
import { map } from 'rxjs'
import { ControlsService } from 'src/app/services/controls.service'
@@ -15,7 +15,6 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data'
import { UILaunchComponent } from './ui-launch.component'
import { i18nPipe } from '@start9labs/shared'
const RUNNING = ['running', 'starting', 'restarting']
@@ -32,19 +31,19 @@ const RUNNING = ['running', 'starting', 'restarting']
{{ 'Stop' | i18n }}
</button>
} @else {
@let unmet = hasUnmet() | async;
<button
*tuiLet="hasUnmet() | async as hasUnmet"
tuiIconButton
iconStart="@tui.play"
[disabled]="status().primary !== 'stopped'"
(click)="controls.start(manifest(), !!hasUnmet)"
(click)="controls.start(manifest(), !!unmet)"
>
{{ 'Start' | i18n }}
</button>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, UILaunchComponent, TuiLet, AsyncPipe, i18nPipe],
imports: [TuiButton, UILaunchComponent, AsyncPipe, i18nPipe],
providers: [tuiButtonOptionsProvider({ size: 's', appearance: 'none' })],
styles: `
:host {

View File

@@ -162,7 +162,7 @@ export class ServiceComponent implements OnChanges {
}
get routerLink() {
return `/portal/services/${this.manifest.id}`
return `/services/${this.manifest.id}`
}
ngOnChanges() {

View File

@@ -1,9 +1,9 @@
import { DOCUMENT } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
DOCUMENT,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'

View File

@@ -82,12 +82,8 @@ export default class ServiceAboutRoute {
action: () => this.copyService.copy(manifest.version),
},
{
name: 'SDK Version',
value: manifest.sdkVersion || '-',
icon: manifest.sdkVersion ? '@tui.copy' : '',
action: () =>
manifest.sdkVersion &&
this.copyService.copy(manifest.sdkVersion),
name: 'Installed From',
value: pkg.registry || NOT_PROVIDED,
},
{
name: 'Git Hash',
@@ -96,6 +92,14 @@ export default class ServiceAboutRoute {
action: () =>
manifest.gitHash && this.copyService.copy(manifest.gitHash),
},
{
name: 'SDK Version',
value: manifest.sdkVersion || '-',
icon: manifest.sdkVersion ? '@tui.copy' : '',
action: () =>
manifest.sdkVersion &&
this.copyService.copy(manifest.sdkVersion),
},
{
name: 'License',
value: manifest.license,
@@ -105,30 +109,35 @@ export default class ServiceAboutRoute {
],
},
{
header: 'Links',
header: 'Source Code',
items: [
{
name: 'Installed From',
value: pkg.registry || NOT_PROVIDED,
},
{
name: 'Service Repository',
name: 'Upstream service',
value: manifest.upstreamRepo,
},
{
name: 'Package Repository',
name: 'StartOS package',
value: manifest.wrapperRepo,
},
],
},
{
header: 'Links',
items: [
{
name: 'Marketing Site',
name: 'Marketing',
value: manifest.marketingSite || NOT_PROVIDED,
},
{
name: 'Support Site',
name: 'Documentation',
value: manifest.docsUrl || NOT_PROVIDED,
},
{
name: 'Support',
value: manifest.supportSite || NOT_PROVIDED,
},
{
name: 'Donation Link',
name: 'Donations',
value: manifest.donationUrl || NOT_PROVIDED,
},
],

View File

@@ -1,5 +1,10 @@
import { KeyValuePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { getPkgId, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -12,29 +17,24 @@ import { StandardActionsService } from 'src/app/services/standard-actions.servic
import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionComponent } from '../components/action.component'
const OTHER = 'Custom Actions'
@Component({
template: `
@if (package(); as pkg) {
@for (group of pkg.actions | keyvalue; track $index) {
@if (group.value.length) {
<section class="g-card">
<header>{{ group.key }}</header>
@for (a of group.value; track $index) {
@if (a.visibility !== 'hidden') {
<button
tuiCell
[action]="a"
(click)="handle(pkg.mainStatus, pkg.icon, pkg.manifest, a)"
></button>
}
}
</section>
}
<section class="g-card">
<header>{{ group.key }}</header>
@for (a of group.value; track $index) {
<button
tuiCell
[action]="a"
(click)="handle(pkg.mainStatus, pkg.icon, pkg.manifest, a)"
></button>
}
</section>
}
<section class="g-card">
<header>{{ 'Standard Actions' | i18n }}</header>
<header>StartOS</header>
<button
tuiCell
[action]="rebuild"
@@ -58,37 +58,56 @@ const OTHER = 'Custom Actions'
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceActionComponent, TuiCell, KeyValuePipe, i18nPipe],
imports: [ServiceActionComponent, TuiCell, KeyValuePipe],
})
export default class ServiceActionsRoute {
private readonly actions = inject(ActionService)
private readonly i18n = inject(i18nPipe)
ungrouped: 'General' | 'Other' = 'General'
readonly service = inject(StandardActionsService)
readonly package = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData', getPkgId())
.pipe(
filter(pkg => pkg.stateInfo.state === 'installed'),
map(pkg => ({
mainStatus: pkg.status.main,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.entries(pkg.actions)
.filter(([_, val]) => val.visibility !== 'hidden')
.reduce<
Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>>
>(
(acc, [id]) => {
const action = { id, ...pkg.actions[id]! }
const group = pkg.actions[id]?.group || OTHER
const current = acc[group] || []
return { ...acc, [group]: current.concat(action) }
},
{ [OTHER]: [] },
),
})),
map(pkg => {
const specialGroup = Object.values(pkg.actions).some(
pkg => !!pkg.group,
)
? 'Other'
: 'General'
return {
mainStatus: pkg.status.main,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.entries(pkg.actions)
.map(([id, action]) => ({
...action,
id,
group: action.group || specialGroup,
}))
.sort((a, b) => {
if (a.group === specialGroup) return 1
if (b.group === specialGroup) return -1
return a.group.localeCompare(b.group) // Optional: sort others alphabetically
})
.reduce<
Record<
string,
Array<T.ActionMetadata & { id: string; group: string }>
>
>((acc, action) => {
const key = action.group
if (!acc[key]) {
acc[key] = []
}
acc[key].push(action)
return acc
}, {}),
}
}),
),
)
@@ -102,7 +121,7 @@ export default class ServiceActionsRoute {
readonly uninstall = {
name: this.i18n.transform('Uninstall')!,
description: this.i18n.transform(
'Uninstalls this service from StartOS and delete all data permanently.',
'Uninstalls this service from StartOS and deletes all data permanently.',
)!,
}

View File

@@ -33,13 +33,15 @@ const INACTIVE: PrimaryStatus[] = [
@if (service()) {
<div *title class="title">
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
<tui-avatar size="xs" [style.margin-inline-end.rem]="0.75">
<img alt="" [src]="service()?.icon" />
</tui-avatar>
<span tuiFade>{{ manifest()?.title }}</span>
<div routerLink="./" class="m-header">
<tui-avatar size="xs" [style.margin-inline-end.rem]="0.75">
<img alt="" [src]="service()?.icon" />
</tui-avatar>
<span tuiFade>{{ manifest()?.title }}</span>
</div>
</div>
<aside class="g-aside">
<header tuiCell>
<header tuiCell routerLink="./">
<tui-avatar><img alt="" [src]="service()?.icon" /></tui-avatar>
<span tuiTitle>
<strong tuiFade>{{ manifest()?.title }}</strong>
@@ -88,6 +90,7 @@ const INACTIVE: PrimaryStatus[] = [
header {
margin: 0 -0.5rem;
cursor: pointer;
}
nav[inert] a:not(:first-child) {
@@ -107,6 +110,11 @@ const INACTIVE: PrimaryStatus[] = [
}
}
.m-header {
cursor: pointer;
display: flex;
}
:host-context(tui-root._mobile) {
flex-direction: column;
padding: 0;
@@ -171,7 +179,6 @@ export class ServiceOutletComponent {
protected readonly nav: { title: i18nKey; icon: string }[] = [
{ title: 'dashboard', icon: '@tui.layout-dashboard' },
{ title: 'actions', icon: '@tui.clapperboard' },
{ title: 'instructions', icon: '@tui.book-open-text' },
{ title: 'logs', icon: '@tui.logs' },
{ title: 'about', icon: '@tui.info' },
]
@@ -186,7 +193,7 @@ export class ServiceOutletComponent {
tap(pkg => {
// if package disappears, navigate to list page
if (!pkg) {
this.router.navigate(['./portal/services'])
this.router.navigate(['services'])
}
}),
),

View File

@@ -1,6 +1,5 @@
import { inject } from '@angular/core'
import { ActivatedRouteSnapshot, ResolveFn, Routes } from '@angular/router'
import { MarkdownComponent } from '@start9labs/shared'
import { defer, map, Observable, of } from 'rxjs'
import { share } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@@ -23,20 +22,6 @@ export const ROUTES: Routes = [
path: 'actions',
loadComponent: () => import('./routes/actions.component'),
},
{
path: 'instructions',
component: MarkdownComponent,
resolve: { content: getStatic('instructions.md') },
canActivate: [
({ paramMap }: ActivatedRouteSnapshot) => {
inject(ApiService)
.setDbValue(['ackInstructions', paramMap.get('pkgId')!], true)
.catch(e => console.error('Failed to mark as seen', e))
return true
},
],
},
{
path: 'interface/:interfaceId',
loadComponent: () => import('./routes/interface.component'),
@@ -48,7 +33,7 @@ export const ROUTES: Routes = [
{
path: 'about',
loadComponent: () => import('./routes/about.component'),
resolve: { content: getStatic('LICENSE.md') },
resolve: { content: getStatic() },
},
],
},
@@ -59,15 +44,13 @@ export const ROUTES: Routes = [
},
]
function getStatic(
path: 'LICENSE.md' | 'instructions.md',
): ResolveFn<Observable<string>> {
function getStatic(): ResolveFn<Observable<string>> {
return ({ paramMap }: ActivatedRouteSnapshot) =>
of(inject(ApiService)).pipe(
map(api =>
defer(() => api.getStaticInstalled(paramMap.get('pkgId')!, path)).pipe(
share(),
),
defer(() =>
api.getStaticInstalled(paramMap.get('pkgId')!, 'LICENSE.md'),
).pipe(share()),
),
)
}

View File

@@ -1,22 +1,14 @@
import { CommonModule } from '@angular/common'
import { Component, inject, input } from '@angular/core'
import { toObservable } from '@angular/core/rxjs-interop'
import {
AboutModule,
AdditionalModule,
MarketplaceAboutComponent,
MarketplaceLinksComponent,
MarketplaceDependenciesComponent,
MarketplacePackageHeroComponent,
MarketplaceReleaseNotesComponent,
} from '@start9labs/marketplace'
import {
DialogService,
Exver,
MARKDOWN,
SharedPipesModule,
} from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { filter, first, map, of, switchMap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { DialogService, MARKDOWN, SharedPipesModule } from '@start9labs/shared'
import { of } from 'rxjs'
import { MarketplaceControlsComponent } from '../marketplace/components/controls.component'
import { MarketplacePkgSideload } from './sideload.utils'
@@ -26,25 +18,18 @@ import { MarketplacePkgSideload } from './sideload.utils'
<div class="outer-container">
<ng-content />
<marketplace-package-hero [pkg]="pkg()">
<marketplace-controls
slot="controls"
class="controls-wrapper"
[version]="pkg().version"
[installAlert]="pkg().alerts.install"
[localPkg]="local$ | async"
[localFlavor]="!!(flavor$ | async)"
[file]="file()"
/>
<marketplace-controls [pkg]="pkg()" [file]="file()" />
</marketplace-package-hero>
<div class="package-details">
<div class="package-details-main">
<marketplace-about [pkg]="pkg()" />
<marketplace-release-notes [pkg]="pkg()" />
@if (!(pkg().dependencyMetadata | empty)) {
<marketplace-dependencies [pkg]="pkg()" />
}
</div>
<div class="package-details-additional">
<marketplace-additional [pkg]="pkg()" (static)="onStatic($event)" />
<marketplace-links [pkg]="pkg()" (static)="onStatic()" />
</div>
</div>
</div>
@@ -62,7 +47,6 @@ import { MarketplacePkgSideload } from './sideload.utils'
}
.package-details {
-moz-column-gap: 2rem;
column-gap: 2rem;
&-main {
@@ -80,62 +64,36 @@ import { MarketplacePkgSideload } from './sideload.utils'
}
&-additional {
grid-column: span 4 / span 4;
margin-top: 0px;
margin-top: 0;
}
}
}
.controls-wrapper {
display: flex;
justify-content: flex-start;
gap: 0.5rem;
height: 4.5rem;
}
`,
imports: [
CommonModule,
SharedPipesModule,
AboutModule,
AdditionalModule,
MarketplaceAboutComponent,
MarketplaceLinksComponent,
MarketplacePackageHeroComponent,
MarketplaceDependenciesComponent,
MarketplaceControlsComponent,
MarketplaceReleaseNotesComponent,
],
})
export class SideloadPackageComponent {
private readonly exver = inject(Exver)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly dialog = inject(DialogService)
readonly pkg = input.required<MarketplacePkgSideload>()
readonly file = input.required<File>()
readonly local$ = toObservable(this.pkg).pipe(
filter(Boolean),
switchMap(({ id, flavor }) =>
this.patch.watch$('packageData', id).pipe(
filter(Boolean),
map(pkg =>
this.exver.getFlavor(getManifest(pkg).version) === flavor
? pkg
: null,
),
),
),
first(),
)
readonly flavor$ = this.local$.pipe(map(pkg => !pkg))
onStatic(type: 'license' | 'instructions') {
const label = type === 'license' ? 'License' : 'Instructions'
const key = type === 'license' ? 'fullLicense' : 'instructions'
onStatic() {
const content = of(this.pkg()['license'])
this.dialog
.openComponent(MARKDOWN, {
label,
label: 'License',
size: 'l',
data: { content: of(this.pkg()[key]) },
data: { content },
})
.subscribe()
}

View File

@@ -5,6 +5,7 @@ import {
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nKey, i18nPipe } from '@start9labs/shared'
import { tuiIsString } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import {
@@ -14,9 +15,9 @@ import {
} from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { TitleDirective } from 'src/app/services/title.service'
import { SideloadPackageComponent } from './package.component'
import { MarketplacePkgSideload, validateS9pk } from './sideload.utils'
import { i18nKey, i18nPipe } from '@start9labs/shared'
@Component({
template: `

View File

@@ -37,12 +37,10 @@ async function parseS9pk(file: File): Promise<MarketplacePkgSideload> {
return {
...s9pk.manifest,
dependencyMetadata: await s9pk.dependencyMetadata(),
gitHash: '',
icon: await s9pk.icon(),
sourceVersion: s9pk.manifest.canMigrateFrom,
flavor: ExtendedVersion.parse(s9pk.manifest.version).flavor,
fullLicense: await s9pk.license(),
instructions: await s9pk.instructions(),
}
}
@@ -77,6 +75,5 @@ function compare(a: Uint8Array, b: Uint8Array) {
}
export type MarketplacePkgSideload = MarketplacePkgBase & {
instructions: string
fullLicense: string
}

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