Compare commits

...

3 Commits

Author SHA1 Message Date
waterplea
2ed7660f9b feat: implement plugins sidebar 2026-03-31 13:56:35 -06:00
Matt Hill
886aa5d7db remove unnecessary warnings 2026-03-31 13:47:20 -06:00
Aiden McClelland
653a0a1428 Merge pull request #2867 from Start9Labs/next/major
StartOS 0.4.0
2026-03-30 19:29:39 -06:00
14 changed files with 256 additions and 79 deletions

View File

@@ -399,7 +399,6 @@ export default {
425: 'Ausführen', 425: 'Ausführen',
426: 'Aktion kann nur ausgeführt werden, wenn der Dienst', 426: 'Aktion kann nur ausgeführt werden, wenn der Dienst',
427: 'Verboten', 427: 'Verboten',
428: 'kann vorübergehend Probleme verursachen',
429: 'hat unerfüllte Abhängigkeiten. Es wird nicht wie erwartet funktionieren.', 429: 'hat unerfüllte Abhängigkeiten. Es wird nicht wie erwartet funktionieren.',
430: 'Container wird neu gebaut', 430: 'Container wird neu gebaut',
431: 'Deinstallation wird gestartet', 431: 'Deinstallation wird gestartet',

View File

@@ -398,7 +398,6 @@ export const ENGLISH: Record<string, number> = {
'Run': 425, // as in, run a piece of software 'Run': 425, // as in, run a piece of software
'Action can only be executed when service is': 426, 'Action can only be executed when service is': 426,
'Forbidden': 427, 'Forbidden': 427,
'may temporarily experiences issues': 428,
'has unmet dependencies. It will not work as expected.': 429, 'has unmet dependencies. It will not work as expected.': 429,
'Rebuilding container': 430, 'Rebuilding container': 430,
'Beginning uninstall': 431, 'Beginning uninstall': 431,

View File

@@ -399,7 +399,6 @@ export default {
425: 'Ejecutar', 425: 'Ejecutar',
426: 'La acción solo se puede ejecutar cuando el servicio está', 426: 'La acción solo se puede ejecutar cuando el servicio está',
427: 'Prohibido', 427: 'Prohibido',
428: 'puede experimentar problemas temporales',
429: 'tiene dependencias no satisfechas. No funcionará como se espera.', 429: 'tiene dependencias no satisfechas. No funcionará como se espera.',
430: 'Reconstruyendo contenedor', 430: 'Reconstruyendo contenedor',
431: 'Iniciando desinstalación', 431: 'Iniciando desinstalación',

View File

@@ -399,7 +399,6 @@ export default {
425: 'Exécuter', 425: 'Exécuter',
426: 'Action possible uniquement lorsque le service est', 426: 'Action possible uniquement lorsque le service est',
427: 'Interdit', 427: 'Interdit',
428: 'peut rencontrer des problèmes temporaires',
429: 'a des dépendances non satisfaites. Il ne fonctionnera pas comme prévu.', 429: 'a des dépendances non satisfaites. Il ne fonctionnera pas comme prévu.',
430: 'Reconstruction du conteneur', 430: 'Reconstruction du conteneur',
431: 'Désinstallation initiée', 431: 'Désinstallation initiée',

View File

@@ -399,7 +399,6 @@ export default {
425: 'Uruchom', 425: 'Uruchom',
426: 'Akcja może być wykonana tylko gdy serwis jest', 426: 'Akcja może być wykonana tylko gdy serwis jest',
427: 'Zabronione', 427: 'Zabronione',
428: 'może tymczasowo napotkać problemy',
429: 'ma niespełnione zależności. Nie będzie działać zgodnie z oczekiwaniami.', 429: 'ma niespełnione zależności. Nie będzie działać zgodnie z oczekiwaniami.',
430: 'Odbudowywanie kontenera', 430: 'Odbudowywanie kontenera',
431: 'Rozpoczynanie odinstalowania', 431: 'Rozpoczynanie odinstalowania',

View File

@@ -37,7 +37,7 @@ import {
VERSION, VERSION,
WorkspaceConfig, WorkspaceConfig,
} from '@start9labs/shared' } from '@start9labs/shared'
import { tuiObfuscateOptionsProvider } from '@taiga-ui/cdk' import { TUI_WINDOW_SIZE, tuiObfuscateOptionsProvider } from '@taiga-ui/cdk'
import { import {
provideTaiga, provideTaiga,
TUI_DIALOGS_CLOSE, TUI_DIALOGS_CLOSE,
@@ -67,11 +67,12 @@ import {
PATCH_CACHE, PATCH_CACHE,
PatchDbSource, PatchDbSource,
} from 'src/app/services/patch-db/patch-db-source' } from 'src/app/services/patch-db/patch-db-source'
import { PluginsService } from 'src/app/services/plugins.service'
import { StateService } from 'src/app/services/state.service' import { StateService } from 'src/app/services/state.service'
import { StorageService } from 'src/app/services/storage.service' import { StorageService } from 'src/app/services/storage.service'
import { import {
DateTransformer,
DatetimeTransformer, DatetimeTransformer,
DateTransformer,
} from 'src/app/utils/value-transformers' } from 'src/app/utils/value-transformers'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@@ -185,5 +186,9 @@ export const APP_CONFIG: ApplicationConfig = {
desktopLarge: Infinity, desktopLarge: Infinity,
}, },
}, },
{
provide: TUI_WINDOW_SIZE,
useExisting: PluginsService,
},
], ],
} }

View File

@@ -16,6 +16,7 @@ import { HeaderStatusComponent } from './status.component'
template: ` template: `
<header-navigation /> <header-navigation />
<div class="item item_center"> <div class="item item_center">
<ng-content />
<div class="mobile"><ng-container #vcr /></div> <div class="mobile"><ng-container #vcr /></div>
</div> </div>
<header-status class="item item_connection" /> <header-status class="item item_connection" />

View File

@@ -0,0 +1,100 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
import { TuiAnimated } from '@taiga-ui/cdk'
import { TuiButton, TuiPopup } from '@taiga-ui/core'
import { ResizerComponent } from 'src/app/routes/portal/components/resizer.component'
import { PluginsService } from 'src/app/services/plugins.service'
@Component({
selector: 'app-plugins',
template: `
<button
tuiIconButton
iconStart="@tui.bot-message-square"
[appearance]="service.enabled() ? 'positive' : 'icon'"
(click)="service.enabled.set(!service.enabled())"
>
AI assistant
</button>
<aside
*tuiPopup="service.enabled()"
tuiAnimated
[class._mobile]="mobile"
[style.--plugins]="service.size() / 100"
(click.self)="onClick($any($event).layerX)"
>
Plugin placeholder
</aside>
<input
*tuiPopup="service.enabled()"
appResizer
type="range"
step="0.1"
[(ngModel)]="service.size"
/>
`,
styles: `
:host {
float: inline-end;
margin-inline-end: 0.5rem;
}
[tuiIconButton] {
background: transparent;
}
aside {
position: fixed;
inset: 0 0 0 calc(320px + (100% - 640px) * var(--plugins));
display: flex;
place-content: center;
place-items: center;
margin: var(--bumper) var(--bumper) var(--bumper) 0;
background: color-mix(in hsl, var(--start9-base-2) 75%, transparent);
background-image: linear-gradient(
transparent,
var(--tui-background-base)
);
backdrop-filter: blur(1rem);
border-radius: var(--bumper);
--tui-from: translateX(100%);
&._mobile {
inset-inline-start: 20%;
box-shadow:
inset 0 1px rgba(255, 255, 255, 0.25),
0 0 0 100vh rgb(0 0 0 / 50%);
&::before {
content: '';
position: fixed;
inset: -100vh;
}
&.tui-enter,
&.tui-leave {
animation-name: tuiSlide, tuiFade;
}
}
&.tui-enter,
&.tui-leave {
animation-name: tuiSlide;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, TuiButton, ResizerComponent, TuiPopup, TuiAnimated],
})
export class PluginsComponent {
protected readonly mobile = inject(WA_IS_MOBILE)
protected readonly service = inject(PluginsService)
protected onClick(layerX: number) {
if (layerX < 0 && this.mobile) {
this.service.enabled.set(false)
}
}
}

View File

@@ -0,0 +1,56 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'input[type="range"][appResizer]',
template: '',
styles: `
@use '@taiga-ui/styles/utils' as taiga;
:host {
@include taiga.transition(color);
position: fixed;
inset: 0 calc(320px - var(--bumper)) 0 calc(320px - 2 * var(--bumper));
appearance: none;
pointer-events: none;
background: none;
color: transparent;
outline: none;
cursor: ew-resize;
&:hover {
color: var(--tui-background-neutral-1-hover);
}
&::-webkit-slider-runnable-track {
block-size: 100%;
}
&::-webkit-slider-thumb {
block-size: 100%;
inline-size: calc(var(--bumper) * 3);
padding: var(--bumper);
appearance: none;
background: currentColor;
background-clip: content-box;
border: none;
pointer-events: auto;
border-radius: var(--tui-radius-l);
}
&::-moz-range-thumb {
block-size: 100%;
inline-size: calc(var(--bumper) * 3);
padding: var(--bumper);
appearance: none;
background: currentColor;
background-clip: content-box;
border: none;
pointer-events: auto;
border-radius: var(--tui-radius-l);
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ResizerComponent {}

View File

@@ -6,6 +6,7 @@ import {
} from '@angular/core' } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { RouterOutlet } from '@angular/router' import { RouterOutlet } from '@angular/router'
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
import { ErrorService, i18nPipe } from '@start9labs/shared' import { ErrorService, i18nPipe } from '@start9labs/shared'
import { import {
TuiButton, TuiButton,
@@ -21,15 +22,17 @@ import {
TuiProgress, TuiProgress,
} from '@taiga-ui/kit' } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { PluginsComponent } from 'src/app/routes/portal/components/plugins.component'
import { TabsComponent } from 'src/app/routes/portal/components/tabs.component' import { TabsComponent } from 'src/app/routes/portal/components/tabs.component'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { OSService } from 'src/app/services/os.service' import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { PluginsService } from 'src/app/services/plugins.service'
import { HeaderComponent } from './components/header/header.component' import { HeaderComponent } from './components/header/header.component'
@Component({ @Component({
template: ` template: `
<header appHeader>{{ name() }}</header> <header appHeader><app-plugins /></header>
<main> <main>
<tui-scrollbar [style.max-height.%]="100"> <tui-scrollbar [style.max-height.%]="100">
<router-outlet /> <router-outlet />
@@ -91,19 +94,47 @@ import { HeaderComponent } from './components/header/header.component'
styles: ` styles: `
@use '@taiga-ui/styles/utils' as taiga; @use '@taiga-ui/styles/utils' as taiga;
@keyframes open {
from {
inline-size: 100%;
}
to {
inline-size: calc(320px + (100% - 640px) * var(--plugins));
}
}
:host { :host {
height: 100%; @include taiga.transition(inline-size);
block-size: 100%;
inline-size: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
// @TODO Theme
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
&::before { &._plugins {
inline-size: calc(320px + (100% - 640px) * var(--plugins));
animation: open var(--tui-duration) ease-in-out;
transition: none;
app-tabs {
inline-size: calc(100% - var(--bumper));
}
}
&::before,
&::after {
content: ''; content: '';
position: fixed; position: fixed;
inset: 0; inset: 0;
backdrop-filter: blur(0.5rem); backdrop-filter: blur(0.5rem);
} }
&::after {
z-index: -1;
// @TODO Theme
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
}
} }
main { main {
@@ -125,6 +156,10 @@ import { HeaderComponent } from './components/header/header.component'
text-wrap: balance; text-wrap: balance;
} }
`, `,
host: {
'[class._plugins]': '!mobile && plugins.enabled()',
'[style.--plugins]': 'plugins.size() / 100',
},
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
RouterOutlet, RouterOutlet,
@@ -139,6 +174,7 @@ import { HeaderComponent } from './components/header/header.component'
TuiPopup, TuiPopup,
TuiCell, TuiCell,
i18nPipe, i18nPipe,
PluginsComponent,
], ],
}) })
export class PortalComponent { export class PortalComponent {
@@ -147,6 +183,8 @@ export class PortalComponent {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
readonly mobile = inject(WA_IS_MOBILE)
readonly plugins = inject(PluginsService)
readonly name = toSignal(this.patch.watch$('serverInfo', 'name')) readonly name = toSignal(this.patch.watch$('serverInfo', 'name'))
readonly update = toSignal(inject(OSService).updating$) readonly update = toSignal(inject(OSService).updating$)
readonly restartReason = toSignal( readonly restartReason = toSignal(

View File

@@ -36,7 +36,7 @@ import { InterfaceService } from '../../../components/interfaces/interface.servi
<button <button
tuiButton tuiButton
iconStart="@tui.rotate-cw" iconStart="@tui.rotate-cw"
(click)="controls.restart(manifest())" (click)="controls.restart(manifest().id)"
> >
{{ 'Restart' | i18n }} {{ 'Restart' | i18n }}
</button> </button>

View File

@@ -10,7 +10,6 @@ import { RouterLink } from '@angular/router'
import { MarketplacePkg } from '@start9labs/marketplace' import { MarketplacePkg } from '@start9labs/marketplace'
import { import {
DialogService, DialogService,
i18nKey,
i18nPipe, i18nPipe,
LocalizePipe, LocalizePipe,
MarkdownPipe, MarkdownPipe,
@@ -32,7 +31,6 @@ import {
TuiProgressCircle, TuiProgressCircle,
} from '@taiga-ui/kit' } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { defaultIfEmpty, firstValueFrom } from 'rxjs'
import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe' import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
import { import {
@@ -41,8 +39,6 @@ import {
PackageDataEntry, PackageDataEntry,
UpdatingState, UpdatingState,
} from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
import { getAllPackages } from 'src/app/utils/get-package-data'
import { hasCurrentDeps } from 'src/app/utils/has-deps'
import UpdatesComponent from './updates.component' import UpdatesComponent from './updates.component'
@Component({ @Component({
@@ -106,7 +102,7 @@ import UpdatesComponent from './updates.component'
size="s" size="s"
[loading]="!ready()" [loading]="!ready()"
[appearance]="error() ? 'destructive' : 'primary'" [appearance]="error() ? 'destructive' : 'primary'"
(click.stop)="onClick()" (click.stop)="update()"
> >
{{ error() ? ('Retry' | i18n) : ('Update' | i18n) }} {{ error() ? ('Retry' | i18n) : ('Update' | i18n) }}
</button> </button>
@@ -274,22 +270,7 @@ export class UpdatesItemComponent {
readonly local = readonly local =
input.required<PackageDataEntry<InstalledState | UpdatingState>>() input.required<PackageDataEntry<InstalledState | UpdatingState>>()
async onClick() { async update() {
this.ready.set(false)
this.error.set('')
if (hasCurrentDeps(this.item().id, await getAllPackages(this.patch))) {
if (await this.alert()) {
await this.update()
} else {
this.ready.set(true)
}
} else {
await this.update()
}
}
private async update() {
const { id, version } = this.item() const { id, version } = this.item()
const url = this.parent.current()?.url || '' const url = this.parent.current()?.url || ''
@@ -301,21 +282,4 @@ export class UpdatesItemComponent {
this.error.set(e.message) this.error.set(e.message)
} }
} }
private async alert(): Promise<boolean> {
return firstValueFrom(
this.dialog
.openConfirm({
label: 'Warning',
size: 's',
data: {
content:
`${this.i18n.transform('Services that depend on')} ${this.local().stateInfo.manifest.title} ${this.i18n.transform('will no longer work properly and may crash.')}` as i18nKey,
yes: 'Continue',
no: 'Cancel',
},
})
.pipe(defaultIfEmpty(false)),
)
}
} }

View File

@@ -84,35 +84,16 @@ export class ControlsService {
}) })
} }
async restart({ id, title }: T.Manifest) { async restart(id: string) {
const packages = await getAllPackages(this.patch) const loader = this.loader.open('Restarting').subscribe()
defer(() => try {
hasCurrentDeps(id, packages) await this.api.restartPackage({ id })
? this.dialog } catch (e: any) {
.openConfirm({ this.errorService.handleError(e)
label: 'Warning', } finally {
size: 's', loader.unsubscribe()
data: { }
content:
`${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('may temporarily experiences issues')}` as i18nKey,
yes: 'Restart',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
: of(null),
).subscribe(async () => {
const loader = this.loader.open('Restarting').subscribe()
try {
await this.api.restartPackage({ id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
} }
private alert(content: T.LocaleString): Promise<boolean> { private alert(content: T.LocaleString): Promise<boolean> {

View File

@@ -0,0 +1,37 @@
import { inject, Injectable, INJECTOR, Injector, signal } from '@angular/core'
import { toObservable } from '@angular/core/rxjs-interop'
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
import { TUI_WINDOW_SIZE } from '@taiga-ui/cdk'
import { TUI_MEDIA } from '@taiga-ui/core'
import { combineLatest, map, Observable } from 'rxjs'
@Injectable({ providedIn: 'root' })
export class PluginsService extends Observable<DOMRect> {
public readonly enabled = signal(false)
public readonly size = signal(100)
// @ts-expect-error triggering TUI_WINDOW_SIZE default factory
private readonly window = Injector.create({
parent: inject(INJECTOR),
providers: [{ provide: TUI_WINDOW_SIZE }],
}).get(TUI_WINDOW_SIZE)
private readonly media = inject(TUI_MEDIA)
private readonly stream = inject(WA_IS_MOBILE)
? this.window
: combineLatest([
this.window,
toObservable(this.size),
toObservable(this.enabled),
]).pipe(
map(([window, size, enabled]) =>
window.width < this.media.mobile || !enabled
? window
: { ...window, width: 320 + (window.width - 640) * (size / 100) },
),
)
constructor() {
super(subscriber => this.stream.subscribe(subscriber))
}
}