Fix/fe bugs 3 (#2943)

* fix typeo in patch db seed

* show all registries in updates tab, fix required dependnecy display in marketplace, update browser tab title desc

* always show pointer for version select

* chore: fix comments

* support html in action desc and marketplace long desc, only show qr in action res if qr is true

* disable save if smtp creds not edited, show better smtp success message

* dont dismiss login spinner until patchDB returns

* feat: redesign of service dashboard and interface (#2946)

* feat: redesign of service dashboard and interface

* chore: comments

* re-add setup complete

* dibale launch UI when not running, re-style things, rename things

* back to 1000

* fix clearnet docs link and require password retype in setup wiz

* faster hint display

* display dependency ID if title not available

* fix migration

* better init progress view

* fix setup success page by providing VERSION and notifications page fixes

* force uninstall from service error page, soft or hard

* handle error state better

* chore: fixed for install and setup wizards

* chore: fix issues (#2949)

* enable and disable kiosk mode

* minor fixes

* fix dependency mounts

* dismissable tasks

* provide replayId

* default if health check success message is null

* look for wifi interface too

* dash for null user agent in sessions

* add disk repair to diagnostic api

---------

Co-authored-by: waterplea <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2025-05-21 19:04:26 -06:00
committed by GitHub
parent 44560c8da8
commit b40849f672
123 changed files with 1662 additions and 964 deletions

View File

@@ -37,7 +37,7 @@
},
"../sdk/dist": {
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.23",
"version": "0.4.0-beta.24",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^3.0.0",

View File

@@ -118,6 +118,7 @@ export class DockerProcedureContainer extends Drop {
subpath: volumeMount.path,
readonly: volumeMount.readonly,
volumeId: volumeMount["volume-id"],
filetype: "directory",
},
})
} else if (volumeMount.type === "backup") {

View File

@@ -1022,6 +1022,7 @@ export class SystemForEmbassy implements System {
volumeId: "embassy",
subpath: null,
readonly: true,
filetype: "directory",
},
})
configFile
@@ -1168,6 +1169,7 @@ async function updateConfig(
volumeId: "embassy",
subpath: null,
readonly: true,
filetype: "directory",
},
})
const remoteConfig = configFile

View File

@@ -348,6 +348,7 @@ pub struct ClearTaskParams {
pub package_id: PackageId,
pub replay_id: ReplayId,
#[arg(long)]
#[serde(default)]
pub force: bool,
}

View File

@@ -7,6 +7,7 @@ use rpc_toolkit::{
};
use crate::context::{CliContext, DiagnosticContext, RpcContext};
use crate::disk::repair;
use crate::init::SYSTEM_REBUILD_PATH;
use crate::prelude::*;
use crate::shutdown::Shutdown;
@@ -95,6 +96,15 @@ pub fn disk<C: Context>() -> ParentHandler<C> {
.no_display()
.with_about("Remove disk from filesystem"),
)
.subcommand("repair", from_fn_async(|_: C| repair()).no_cli())
.subcommand(
"repair",
CallRemoteHandler::<CliContext, _, _>::new(
from_fn_async(|_: RpcContext| repair())
.no_display()
.with_about("Repair disk in the event of corruption"),
),
)
}
pub async fn forget_disk<C: Context>(_: C) -> Result<(), Error> {

View File

@@ -3,28 +3,73 @@ use std::path::Path;
use digest::generic_array::GenericArray;
use digest::{Digest, OutputSizeUser};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use ts_rs::TS;
use super::FileSystem;
use crate::prelude::*;
use crate::util::io::create_file;
pub struct Bind<SrcDir: AsRef<Path>> {
src_dir: SrcDir,
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "kebab-case")]
pub enum FileType {
File,
Directory,
Infer,
}
impl<SrcDir: AsRef<Path>> Bind<SrcDir> {
pub fn new(src_dir: SrcDir) -> Self {
Self { src_dir }
pub struct Bind<Src: AsRef<Path>> {
src: Src,
filetype: FileType,
}
impl<Src: AsRef<Path>> Bind<Src> {
pub fn new(src: Src) -> Self {
Self {
src,
filetype: FileType::Directory,
}
}
pub fn with_type(mut self, filetype: FileType) -> Self {
self.filetype = filetype;
self
}
}
impl<SrcDir: AsRef<Path> + Send + Sync> FileSystem for Bind<SrcDir> {
impl<Src: AsRef<Path> + Send + Sync> FileSystem for Bind<Src> {
async fn source(&self) -> Result<Option<impl AsRef<Path>>, Error> {
Ok(Some(&self.src_dir))
Ok(Some(&self.src))
}
fn extra_args(&self) -> impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>> {
["--bind"]
}
async fn pre_mount(&self) -> Result<(), Error> {
tokio::fs::create_dir_all(self.src_dir.as_ref()).await?;
async fn pre_mount(&self, mountpoint: &Path) -> Result<(), Error> {
let from_meta = tokio::fs::metadata(&self.src).await.ok();
let to_meta = tokio::fs::metadata(&mountpoint).await.ok();
if matches!(self.filetype, FileType::File)
|| (matches!(self.filetype, FileType::Infer)
&& from_meta.as_ref().map_or(false, |m| m.is_file()))
{
if to_meta.as_ref().map_or(false, |m| m.is_dir()) {
tokio::fs::remove_dir(mountpoint).await?;
}
if from_meta.is_none() {
create_file(self.src.as_ref()).await?.sync_all().await?;
}
if to_meta.is_none() {
create_file(mountpoint).await?.sync_all().await?;
}
} else {
if to_meta.as_ref().map_or(false, |m| m.is_file()) {
tokio::fs::remove_file(mountpoint).await?;
}
if from_meta.is_none() {
tokio::fs::create_dir_all(self.src.as_ref()).await?;
}
if to_meta.is_none() {
tokio::fs::create_dir_all(mountpoint).await?;
}
}
Ok(())
}
async fn source_hash(
@@ -33,12 +78,12 @@ impl<SrcDir: AsRef<Path> + Send + Sync> FileSystem for Bind<SrcDir> {
let mut sha = Sha256::new();
sha.update("Bind");
sha.update(
tokio::fs::canonicalize(self.src_dir.as_ref())
tokio::fs::canonicalize(self.src.as_ref())
.await
.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
self.src_dir.as_ref().display().to_string(),
self.src.as_ref().display().to_string(),
)
})?
.as_os_str()

View File

@@ -49,8 +49,7 @@ impl<EncryptedDir: AsRef<Path> + Send + Sync, Key: AsRef<str> + Send + Sync> Fil
mountpoint: P,
mount_type: super::MountType,
) -> Result<(), Error> {
self.pre_mount().await?;
tokio::fs::create_dir_all(mountpoint.as_ref()).await?;
self.pre_mount(mountpoint.as_ref()).await?;
Command::new("mount")
.args(
default_mount_command(self, mountpoint, mount_type)

View File

@@ -53,16 +53,15 @@ impl<Fs: FileSystem> FileSystem for IdMapped<Fs> {
async fn source(&self) -> Result<Option<impl AsRef<Path>>, Error> {
self.filesystem.source().await
}
async fn pre_mount(&self) -> Result<(), Error> {
self.filesystem.pre_mount().await
async fn pre_mount(&self, mountpoint: &Path) -> Result<(), Error> {
self.filesystem.pre_mount(mountpoint).await
}
async fn mount<P: AsRef<Path> + Send>(
&self,
mountpoint: P,
mount_type: MountType,
) -> Result<(), Error> {
self.pre_mount().await?;
tokio::fs::create_dir_all(mountpoint.as_ref()).await?;
self.pre_mount(mountpoint.as_ref()).await?;
Command::new("mount.next")
.args(
default_mount_command(self, mountpoint, mount_type)

View File

@@ -69,8 +69,7 @@ pub(self) async fn default_mount_impl(
mountpoint: impl AsRef<Path> + Send,
mount_type: MountType,
) -> Result<(), Error> {
fs.pre_mount().await?;
tokio::fs::create_dir_all(mountpoint.as_ref()).await?;
fs.pre_mount(mountpoint.as_ref()).await?;
Command::from(default_mount_command(fs, mountpoint, mount_type).await?)
.capture(false)
.invoke(ErrorKind::Filesystem)
@@ -92,8 +91,11 @@ pub trait FileSystem: Send + Sync {
fn source(&self) -> impl Future<Output = Result<Option<impl AsRef<Path>>, Error>> + Send {
async { Ok(None::<&Path>) }
}
fn pre_mount(&self) -> impl Future<Output = Result<(), Error>> + Send {
async { Ok(()) }
fn pre_mount(&self, mountpoint: &Path) -> impl Future<Output = Result<(), Error>> + Send {
async move {
tokio::fs::create_dir_all(mountpoint).await?;
Ok(())
}
}
fn mount<P: AsRef<Path> + Send>(
&self,

View File

@@ -41,9 +41,10 @@ impl<
Box::new(lazy_format!("workdir={}", self.work.as_ref().display())),
]
}
async fn pre_mount(&self) -> Result<(), Error> {
async fn pre_mount(&self, mountpoint: &Path) -> Result<(), Error> {
tokio::fs::create_dir_all(self.upper.as_ref()).await?;
tokio::fs::create_dir_all(self.work.as_ref()).await?;
tokio::fs::create_dir_all(mountpoint).await?;
Ok(())
}
async fn source_hash(

View File

@@ -10,10 +10,10 @@ use models::{FromStrParser, HealthCheckId, PackageId, ReplayId, VersionString, V
use tokio::process::Command;
use crate::db::model::package::{
TaskEntry, CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind,
ManifestPreference,
CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference,
TaskEntry,
};
use crate::disk::mount::filesystem::bind::Bind;
use crate::disk::mount::filesystem::bind::{Bind, FileType};
use crate::disk::mount::filesystem::idmapped::IdMapped;
use crate::disk::mount::filesystem::{FileSystem, MountType};
use crate::disk::mount::util::{is_mountpoint, unmount};
@@ -23,14 +23,6 @@ use crate::util::Invoke;
use crate::volume::data_dir;
use crate::DATA_DIR;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub enum FileType {
File,
Directory,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
@@ -39,8 +31,7 @@ pub struct MountTarget {
volume_id: VolumeId,
subpath: Option<PathBuf>,
readonly: bool,
#[ts(optional)]
filetype: Option<FileType>,
filetype: FileType,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
@@ -67,7 +58,6 @@ pub async fn mount(
let subpath = subpath.unwrap_or_default();
let subpath = subpath.strip_prefix("/").unwrap_or(&subpath);
let source = data_dir(DATA_DIR, &package_id, &volume_id).join(subpath);
let from_meta = tokio::fs::metadata(&source).await.ok();
let location = location.strip_prefix("/").unwrap_or(&location);
let mountpoint = context
.seed
@@ -77,39 +67,7 @@ pub async fn mount(
.or_not_found("lxc container")?
.rootfs_dir()
.join(location);
let to_meta = tokio::fs::metadata(&mountpoint).await.ok();
if matches!(filetype, Some(FileType::File))
|| (filetype.is_none() && from_meta.as_ref().map_or(false, |m| m.is_file()))
{
if to_meta.as_ref().map_or(false, |m| m.is_dir()) {
tokio::fs::remove_dir(&mountpoint).await?;
}
if from_meta.is_none() {
if let Some(parent) = source.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&source, "").await?;
}
if to_meta.is_none() {
if let Some(parent) = mountpoint.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&mountpoint, "").await?;
}
} else {
if to_meta.as_ref().map_or(false, |m| m.is_file()) {
tokio::fs::remove_file(&mountpoint).await?;
}
if from_meta.is_none() {
tokio::fs::create_dir_all(&source).await?;
}
if to_meta.is_none() {
tokio::fs::create_dir_all(&mountpoint).await?;
}
}
tokio::fs::create_dir_all(&mountpoint).await?;
if is_mountpoint(&mountpoint).await? {
unmount(&mountpoint, true).await?;
}
@@ -118,7 +76,7 @@ pub async fn mount(
.arg(&mountpoint)
.invoke(crate::ErrorKind::Filesystem)
.await?;
IdMapped::new(Bind::new(source), 0, 100000, 65536)
IdMapped::new(Bind::new(source).with_type(filetype), 0, 100000, 65536)
.mount(
mountpoint,
if readonly {

View File

@@ -211,27 +211,12 @@ impl VersionT for Version {
}
fn up(self, db: &mut Value, (account, ssh_keys, cifs): Self::PreUpRes) -> Result<(), Error> {
let wifi = json!({
"infterface": db["server-info"]["wifi"]["interface"],
"interface": db["server-info"]["wifi"]["interface"],
"ssids": db["server-info"]["wifi"]["ssids"],
"selected": db["server-info"]["wifi"]["selected"],
"last_region": db["server-info"]["wifi"]["last-region"],
"lastRegion": db["server-info"]["wifi"]["last-region"],
});
let ip_info = {
let mut ip_info = json!({});
let empty = Default::default();
for (k, v) in db["server-info"]["ip-info"].as_object().unwrap_or(&empty) {
let k: &str = k.as_ref();
ip_info[k] = json!({
"ipv4Range": v["ipv4-range"],
"ipv6Range": v["ipv6-range"],
"ipv4": v["ipv4"],
"ipv6": v["ipv6"],
});
}
ip_info
};
let status_info = json!({
"backupProgress": db["server-info"]["status-info"]["backup-progress"],
"updated": db["server-info"]["status-info"]["updated"],
@@ -259,7 +244,7 @@ impl VersionT for Version {
.replace("https://", "")
.replace("http://", "")
.replace(".onion/", ""));
server_info["ipInfo"] = ip_info;
server_info["networkInterfaces"] = json!({});
server_info["statusInfo"] = status_info;
server_info["wifi"] = wifi;
server_info["unreadNotificationCount"] =

View File

@@ -1,4 +1,3 @@
import { ExtendedVersion, VersionRange } from "./exver"
import {
ActionId,
ActionInput,
@@ -15,6 +14,7 @@ import {
ServiceInterface,
CreateTaskParams,
MainStatus,
MountParams,
} from "./osBindings"
import {
PackageId,
@@ -23,7 +23,6 @@ import {
SmtpValue,
ActionResult,
} from "./types"
import { UrlString } from "./util/getServiceInterface"
/** Used to reach out from the pure js runtime */
@@ -80,15 +79,7 @@ export type Effects = {
packageIds?: PackageId[]
}): Promise<CheckDependenciesResult[]>
/** mount a volume of a dependency */
mount(options: {
location: string
target: {
packageId: string
volumeId: string
subpath: string | null
readonly: boolean
}
}): Promise<string>
mount(options: MountParams): Promise<string>
/** Returns a list of the ids of all installed packages */
getInstalledPackages(): Promise<string[]>

View File

@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FileType = "file" | "directory"
export type FileType = "file" | "directory" | "infer"

View File

@@ -8,5 +8,5 @@ export type MountTarget = {
volumeId: VolumeId
subpath: string | null
readonly: boolean
filetype?: FileType
filetype: FileType
}

View File

@@ -412,7 +412,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
id: string
/** The human readable description. */
description: string
/** Affects how the interface appears to the user. One of: 'ui', 'api', 'p2p'. If 'ui', the user will see a "Launch UI" button */
/** Affects how the interface appears to the user. One of: 'ui', 'api', 'p2p'. If 'ui', the user will see an option to open the UI in a new tab */
type: ServiceInterfaceType
/** (optional) prepends the provided username to all URLs. */
username: null | string

View File

@@ -187,10 +187,10 @@ export class FileHelper<A> {
/**
* Reads the file from disk and converts it to structured data.
*/
private async readOnce(): Promise<A | null> {
private async readOnce<B>(map: (value: A) => B): Promise<B | null> {
const data = await this.readFile()
if (!data) return null
return this.validate(data)
return map(this.validate(data))
}
private async readConst<B>(
@@ -224,8 +224,7 @@ export class FileHelper<A> {
persistent: false,
signal: ctrl.signal,
})
const newResFull = await this.readOnce()
const newRes = newResFull ? map(newResFull) : null
const newRes = await this.readOnce(map)
const listen = Promise.resolve()
.then(async () => {
for await (const _ of watch) {
@@ -284,7 +283,7 @@ export class FileHelper<A> {
map = map ?? ((a: A) => a)
eq = eq ?? ((left: any, right: any) => !partialDiff(left, right))
return {
once: () => this.readOnce(),
once: () => this.readOnce(map),
const: (effects: T.Effects) => this.readConst(effects, map, eq),
watch: (effects: T.Effects) => this.readWatch(effects, map, eq),
onChange: (

View File

@@ -1,12 +1,12 @@
{
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.23",
"version": "0.4.0-beta.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.23",
"version": "0.4.0-beta.24",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^3.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.23",
"version": "0.4.0-beta.24",
"description": "Software development kit to facilitate packaging services for StartOS",
"main": "./package/lib/index.js",
"types": "./package/lib/index.d.ts",

View File

@@ -4,7 +4,7 @@
"https://registry.start9.com/": "Start9 Registry",
"https://community-registry.start9.com/": "Community Registry"
},
"startosRegisrty": "https://registry.start9.com/",
"startosRegistry": "https://registry.start9.com/",
"snakeHighScore": 0,
"ackInstructions": {}
}

View File

@@ -61,3 +61,8 @@ main {
margin-left: -100%;
}
}
[tuiCell]:not(:last-of-type) {
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
}

View File

@@ -62,7 +62,12 @@ export class AppComponent {
this.dialogs
.open(
'Please wait for StartOS to restart, then refresh this page',
{ label: 'Rebooting', size: 's' },
{
label: 'Rebooting',
size: 's',
closeable: false,
dismissible: false,
},
)
.subscribe()
} catch (e: any) {

View File

@@ -12,7 +12,7 @@
Past Release Notes
</button>
<h2 class="additional-detail-title" [style.margin-top.rem]="2">About</h2>
<p>{{ pkg.description.long }}</p>
<p [innerHTML]="pkg.description.long"></p>
<a
*ngIf="pkg.marketingSite as url"
tuiButton

View File

@@ -20,8 +20,8 @@ import { MarketplacePkgBase } from '../../../types'
</span>
<p>
<ng-container [ngSwitch]="dep.value.optional">
<span *ngSwitchCase="true">(required)</span>
<span *ngSwitchCase="false">(optional)</span>
<span *ngSwitchCase="true">(optional)</span>
<span *ngSwitchCase="false">(required)</span>
</ng-container>
</p>
</div>

View File

@@ -2,6 +2,8 @@ import { Component, inject } 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',
@@ -11,9 +13,15 @@ export class AppComponent {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly router = inject(Router)
private readonly stateService = inject(StateService)
private readonly document = inject(DOCUMENT)
async ngOnInit() {
try {
this.stateService.kiosk = ['localhost', '127.0.0.1'].includes(
this.document.location.hostname,
)
const inProgress = await this.api.getStatus()
let route = 'home'

View File

@@ -5,6 +5,7 @@ import { PreloadAllModules, RouterModule } from '@angular/router'
import {
provideSetupLogsService,
RELATIVE_URL,
VERSION,
WorkspaceConfig,
} from '@start9labs/shared'
import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core'
@@ -20,6 +21,8 @@ const {
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
const version = require('../../../../package.json').version
@NgModule({
declarations: [AppComponent],
imports: [
@@ -43,6 +46,10 @@ const {
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
{
provide: VERSION,
useValue: version,
},
],
bootstrap: [AppComponent],
})

View File

@@ -8,7 +8,7 @@ const FADE_FACTOR = 0.07
standalone: true,
selector: 'canvas[matrix]',
template: 'Your browser does not support the canvas element.',
styles: ':host { position: fixed; }',
styles: ':host { position: fixed; top: 0 }',
})
export class MatrixComponent implements OnInit {
private readonly ngZone = inject(NgZone)

View File

@@ -1,9 +1,27 @@
import { AsyncPipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
AbstractControl,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import * as argon2 from '@start9labs/argon2'
import { ErrorService } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiError } from '@taiga-ui/core'
import { TuiInputPasswordModule } from '@taiga-ui/legacy'
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogContext,
TuiError,
TuiIcon,
TuiTextfield,
} from '@taiga-ui/core'
import {
TuiFieldErrorPipe,
TuiPassword,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
interface DialogData {
@@ -21,18 +39,38 @@ interface DialogData {
Enter the password that was used to encrypt this drive.
}
<form [style.margin-top.rem]="1" (ngSubmit)="submit()">
<tui-input-password [formControl]="password">
Enter Password
<input tuiTextfieldLegacy maxlength="64" />
</tui-input-password>
<tui-error [error]="passwordError"></tui-error>
<form [formGroup]="form" [style.margin-top.rem]="1" (ngSubmit)="submit()">
<tui-textfield>
<label tuiLabel>Enter Password</label>
<input
tuiTextfield
type="password"
tuiAutoFocus
maxlength="64"
formControlName="password"
/>
<tui-icon tuiPassword />
</tui-textfield>
<tui-error
formControlName="password"
[error]="[] | tuiFieldError | async"
/>
@if (storageDrive) {
<tui-input-password [style.margin-top.rem]="1" [formControl]="confirm">
Retype Password
<input tuiTextfieldLegacy maxlength="64" />
</tui-input-password>
<tui-error [error]="confirmError"></tui-error>
<tui-textfield [style.margin-top.rem]="1">
<label tuiLabel>Retype Password</label>
<input
tuiTextfield
type="password"
maxlength="64"
formControlName="confirm"
[tuiValidator]="form.controls.password.value | tuiMapper: validator"
/>
<tui-icon tuiPassword />
</tui-textfield>
<tui-error
formControlName="confirm"
[error]="[] | tuiFieldError | async"
/>
}
<footer>
<button
@@ -43,22 +81,38 @@ interface DialogData {
>
Cancel
</button>
<button
tuiButton
[disabled]="!password.value || !!confirmError || !!passwordError"
>
<button tuiButton [disabled]="form.invalid">
{{ storageDrive ? 'Finish' : 'Unlock' }}
</button>
</footer>
</form>
`,
styles: ['footer { display: flex; gap: 1rem; margin-top: 1rem }'],
styles: `
footer {
display: flex;
gap: 1rem;
margin-top: 1rem;
justify-content: flex-end;
}
`,
imports: [
FormsModule,
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiInputPasswordModule,
TuiError,
TuiAutoFocus,
TuiFieldErrorPipe,
TuiTextfield,
TuiPassword,
TuiValidator,
TuiIcon,
TuiMapperPipe,
],
providers: [
tuiValidationErrorsProvider({
required: 'Required',
minlength: 'Must be 12 characters or greater',
}),
],
})
export class PasswordComponent {
@@ -67,31 +121,29 @@ export class PasswordComponent {
injectContext<TuiDialogContext<string, DialogData>>()
readonly storageDrive = this.context.data.storageDrive
readonly password = new FormControl('', { nonNullable: true })
readonly confirm = new FormControl('', { nonNullable: true })
readonly form = new FormGroup({
password: new FormControl('', [
Validators.required,
Validators.minLength(12),
]),
confirm: new FormControl('', this.storageDrive ? Validators.required : []),
})
get passwordError(): string | null {
return this.password.touched && this.password.value.length < 12
? 'Must be 12 characters or greater'
: null
}
get confirmError(): string | null {
return this.confirm.touched && this.password.value !== this.confirm.value
? 'Passwords do not match'
: null
}
readonly validator = (value: any) => (control: AbstractControl) =>
value === control.value ? null : { match: 'Passwords do not match' }
submit() {
const password = this.form.controls.password.value || ''
if (this.storageDrive) {
this.context.completeWith(this.password.value)
this.context.completeWith(password)
return
}
try {
argon2.verify(this.context.data.passwordHash || '', this.password.value)
this.context.completeWith(this.password.value)
argon2.verify(this.context.data.passwordHash || '', password)
this.context.completeWith(password)
} catch (e) {
this.errorService.handleError('Incorrect password provided')
}

View File

@@ -17,7 +17,7 @@ import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<section tuiCardLarge="compact">
<header>Use existing drive</header>
<div>Select the physical drive containing your StartOS data</div>
@@ -31,9 +31,11 @@ import { StateService } from 'src/app/services/state.service'
valid StartOS data drive (not a backup) and is firmly connected, then
refresh the page.
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
}
</section>
`,

View File

@@ -13,7 +13,7 @@ import { StateService } from 'src/app/services/state.service'
template: `
<img class="logo" src="assets/img/icon.png" alt="Start9" />
@if (!loading) {
<section tuiCardLarge>
<section tuiCardLarge="compact">
<header [style.padding-top.rem]="1.25">
@if (recover) {
<button
@@ -30,7 +30,7 @@ import { StateService } from 'src/app/services/state.service'
</header>
<div class="pages">
<div class="options" [class.options_recover]="recover">
<a tuiCell [routerLink]="error || recover ? null : '/storage'">
<button tuiCell [routerLink]="error || recover ? null : '/storage'">
<tui-icon icon="@tui.plus" />
<span tuiTitle>
<span class="g-positive">Start Fresh</span>
@@ -38,7 +38,7 @@ import { StateService } from 'src/app/services/state.service'
Get started with a brand new Start9 server
</span>
</span>
</a>
</button>
<button
tuiCell
[disabled]="error || recover"

View File

@@ -17,7 +17,7 @@ import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<section tuiCardLarge="compact">
<header>Restore from Backup</header>
@if (loading) {
<tui-loader />
@@ -26,7 +26,7 @@ import { StateService } from 'src/app/services/state.service'
Restore StartOS data from a folder on another computer that is connected
to the same network as your server.
<button tuiCell (click)="onCifs()">
<button tuiCell [style.box-shadow]="'none'" (click)="onCifs()">
<tui-icon icon="@tui.folder" />
<span tuiTitle>Open</span>
</button>
@@ -49,10 +49,11 @@ import { StateService } from 'src/app/services/state.service'
(password)="select($event, server)"
></button>
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
}
</section>
`,

View File

@@ -19,7 +19,7 @@ import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<section tuiCardLarge="compact">
@if (loading || drives.length) {
<header>Select storage drive</header>
This is the drive where your StartOS data will be stored.
@@ -39,10 +39,11 @@ import { StateService } from 'src/app/services/state.service'
}
</button>
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
</section>
`,
imports: [TuiCardLarge, TuiLoader, TuiCell, TuiButton, DriveComponent],

View File

@@ -18,15 +18,13 @@ import { StateService } from 'src/app/services/state.service'
standalone: true,
template: `
<canvas matrix></canvas>
@if (isKiosk) {
@if (stateService.kiosk) {
<section tuiCardLarge>
<h1 class="heading">
<tui-icon icon="@tui.check-square" class="g-positive" />
Setup Complete!
</h1>
<button tuiButton (click)="exitKiosk()" iconEnd="@tui.log-in">
Continue to Login
</button>
<button tuiButton (click)="exitKiosk()">Continue to Login</button>
</section>
} @else if (lanAddress) {
<section tuiCardLarge>
@@ -111,16 +109,12 @@ import { StateService } from 'src/app/services/state.service'
export default class SuccessPage implements AfterViewInit {
@ViewChild(DocumentationComponent, { read: ElementRef })
private readonly documentation?: ElementRef<HTMLElement>
private readonly document = inject(DOCUMENT)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly downloadHtml = inject(DownloadHTMLService)
readonly stateService = inject(StateService)
readonly isKiosk = ['localhost', '127.0.0.1'].includes(
this.document.location.hostname,
)
torAddresses?: string[]
lanAddress?: string
@@ -157,7 +151,7 @@ export default class SuccessPage implements AfterViewInit {
private async complete() {
try {
const ret = await this.api.complete()
if (!this.isKiosk) {
if (!this.stateService.kiosk) {
this.torAddresses = ret.torAddresses.map(a =>
a.replace(/^https:/, 'http:'),
)

View File

@@ -21,7 +21,7 @@ import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<section tuiCardLarge="compact">
<header>Transfer</header>
Select the physical drive containing your StartOS data
@if (loading) {
@@ -30,9 +30,11 @@ import { StateService } from 'src/app/services/state.service'
@for (drive of drives; track drive) {
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
</section>
`,
imports: [TuiCardLarge, TuiCell, TuiButton, TuiLoader, DriveComponent],

View File

@@ -8,6 +8,7 @@ import { T } from '@start9labs/start-sdk'
export class StateService {
private readonly api = inject(ApiService)
kiosk?: boolean
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
recoverySource?: T.RecoverySource<string>
@@ -15,6 +16,7 @@ export class StateService {
await this.api.attach({
guid,
startOsPassword: await this.api.encrypt(password),
kiosk: this.kiosk,
})
}
@@ -33,6 +35,7 @@ export class StateService {
password: await this.api.encrypt(this.recoverySource.password),
}
: null,
kiosk: this.kiosk,
})
}
}

View File

@@ -24,7 +24,7 @@ router-outlet + * {
[tuiCardLarge] {
width: 100%;
background: var(--tui-background-base-alt);
background: var(--tui-background-elevation-2);
margin: auto;
}
}
@@ -67,3 +67,11 @@ h2 {
.g-info {
color: var(--tui-status-info);
}
[tuiCardLarge] footer button {
width: 100%;
}
[tuiCell]:not(:last-of-type) {
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

View File

@@ -12,7 +12,7 @@ import { i18nPipe } from '../i18n/i18n.pipe'
<h1 [style.font-size.rem]="2" [style.margin-bottom.rem]="2">
{{ 'Setting up your server' | i18n }}
</h1>
<div *ngIf="progress.total">
<div>
{{ 'Progress' | i18n }}: {{ (progress.total * 100).toFixed(0) }}%
</div>
<progress
@@ -21,7 +21,7 @@ import { i18nPipe } from '../i18n/i18n.pipe'
[style.margin]="'1rem auto'"
[attr.value]="progress.total"
></progress>
<p [innerHTML]="progress.message"></p>
<p [innerHTML]="progress.message || 'Finished'"></p>
</section>
<logs-window />
`,

View File

@@ -21,7 +21,6 @@ import {
&:hover {
text-indent: var(--indent, 0);
text-overflow: clip;
cursor: default;
}
}
`,

View File

@@ -6,7 +6,6 @@ import {
input,
} from '@angular/core'
const HOST = 'https://staging.docs.start9.com'
export const VERSION = new InjectionToken<string>('VERSION')
@Directive({
@@ -26,6 +25,6 @@ export class DocsLinkDirective {
protected readonly url = computed(() => {
const path = this.href()
const relative = path.startsWith('/') ? path : `/${path}`
return `${HOST}${relative}?os=${this.version}`
return `https://docs.start9.com${relative}?os=${this.version}`
})
}

View File

@@ -60,7 +60,7 @@ export default {
57: 'Herunterfahren wird eingeleitet',
58: 'Hinzufügen',
59: 'Ok',
60: 'Möchten Sie diesen Eintrag wirklich löschen?',
60: 'französisch',
61: 'Dieser Wert kann nach dem Festlegen nicht geändert werden',
62: 'Fortfahren',
63: 'Klicken oder Datei hierher ziehen',
@@ -85,7 +85,7 @@ export default {
82: 'Metriken',
83: 'Protokolle',
84: 'Benachrichtigungen',
85: 'UI starten',
85: 'Hartes Deinstallieren',
86: 'QR-Code anzeigen',
87: 'URL kopieren',
88: 'Aktionen',
@@ -230,9 +230,9 @@ export default {
227: 'Unbekannter Fehler',
228: 'Fehler',
229: '"Container neu bauen" ist eine harmlose Aktion, die nur wenige Sekunden dauert. Sie wird dieses Problem wahrscheinlich beheben.',
230: '"Dienst deinstallieren" ist eine gefährliche Aktion, die den Dienst aus StartOS entfernt und alle zugehörigen Daten dauerhaft löscht.',
230: '"Hartes Deinstallieren" ist eine gefährliche Aktion, die den Dienst aus StartOS entfernt und alle zugehörigen Daten dauerhaft löscht.',
231: 'Container neu bauen',
232: 'Dienst deinstallieren',
232: 'Weiches Deinstallieren',
233: 'Vollständige Nachricht anzeigen',
234: 'Dienstfehler',
235: 'Warte auf Ergebnis',
@@ -247,7 +247,6 @@ export default {
244: 'Hosting',
245: 'Installation läuft',
246: 'Siehe unten',
247: 'Steuerelemente',
248: 'Keine Dienste installiert',
249: 'Läuft',
250: 'Gestoppt',
@@ -414,12 +413,12 @@ export default {
411: 'Weitere Netzwerke',
412: 'WiFi ist deaktiviert',
413: 'Keine drahtlose Schnittstelle erkannt',
414: 'WiFi wird aktiviert',
415: 'WiFi wird deaktiviert',
414: 'wird aktiviert',
415: 'wird deaktiviert',
416: 'Verbindung wird hergestellt. Dies kann einen Moment dauern',
417: 'Erneut versuchen',
418: 'Mehr anzeigen',
419: 'Versionshinweise',
419: 'Details anzeigen',
420: 'Eintrag anzeigen',
421: 'Dienste, die von folgendem abhängen:',
422: 'werden nicht mehr ordnungsgemäß funktionieren und könnten abstürzen.',
@@ -503,5 +502,20 @@ export default {
500: 'Marktplatz anzeigen',
501: 'Willkommen bei',
502: 'souveränes computing',
503: 'französisch',
503: 'Passen Sie den Namen an, der in Ihrem Browser-Tab erscheint',
504: 'Verwalten',
505: 'Möchten Sie diese Adresse wirklich löschen?',
506: '"Weiches Deinstallieren" entfernt den Dienst aus StartOS, behält jedoch die Daten bei.',
507: 'Keine gespeicherten Anbieter',
508: 'Kiosk-Modus',
509: 'Aktiviert',
510: 'Deaktiviere den Kiosk-Modus, es sei denn, du musst einen Monitor anschließen',
511: 'Aktiviere den Kiosk-Modus, wenn du einen Monitor anschließen musst',
512: 'Der Kiosk-Modus ist auf diesem Gerät nicht verfügbar',
513: 'Aktivieren',
514: 'Deaktivieren',
515: 'Du verwendest derzeit einen Kiosk. Wenn du den Kiosk-Modus deaktivierst, wird die Verbindung zum Kiosk getrennt.',
516: 'Empfohlen',
517: 'Möchten Sie diese Aufgabe wirklich verwerfen?',
518: 'Verwerfen',
} satisfies i18n

View File

@@ -44,7 +44,7 @@ export const ENGLISH = {
'Beginning restart': 42,
'You are on the latest version of StartOS.': 43,
'Up to date!': 44,
'Release Notes': 45,
'Release notes': 45,
'Begin Update': 46,
'Beginning update': 47,
'You are currently connected over Tor. If you reset the Tor daemon, you will lose connectivity until it comes back online.': 48,
@@ -59,7 +59,7 @@ export const ENGLISH = {
'Beginning shutdown': 57,
'Add': 58,
'Ok': 59,
'Are you sure you want to delete this entry?': 60,
'french': 60,
'This value cannot be changed once set': 61,
'Continue': 62,
'Click or drop file here': 63,
@@ -84,7 +84,7 @@ export const ENGLISH = {
'Metrics': 82, // system info such as CPU, RAM, and storage usage
'Logs': 83, // as in, application logs
'Notifications': 84,
'Launch UI': 85,
'Hard uninstall': 85, // as in, hard reset or hard reboot, except for uninstalling
'Show QR': 86,
'Copy URL': 87,
'Actions': 88, // as in, actions available to the user
@@ -229,9 +229,9 @@ export const ENGLISH = {
'Unknown error': 227,
'Error': 228,
'"Rebuild container" is a harmless action that and only takes a few seconds to complete. It will likely resolve this issue.': 229,
'"Uninstall service" is a dangerous action that will remove the service from StartOS and wipe all its data.': 230,
'"Hard uninstall" is a dangerous action that will remove the service from StartOS and wipe all its data.': 230,
'Rebuild container': 231,
'Uninstall service': 232,
'Soft uninstall': 232, // as in, uninstall the service but preserve its data
'View full message': 233,
'Service error': 234,
'Awaiting result': 235,
@@ -246,7 +246,6 @@ export const ENGLISH = {
'Hosting': 244,
'Installing': 245,
'See below': 246,
'Controls': 247,
'No services installed': 248,
'Running': 249,
'Stopped': 250,
@@ -413,12 +412,12 @@ export const ENGLISH = {
'Other Networks': 411,
'WiFi is disabled': 412,
'No wireless interface detected': 413,
'Enabling WiFi': 414,
'Disabling WiFi': 415,
'Enabling': 414,
'Disabling': 415,
'Connecting. This could take a while': 416,
'Retry': 417,
'Show more': 418,
'Release notes': 419,
'View details': 419,
'View listing': 420,
'Services that depend on': 421,
'will no longer work properly and may crash.': 422,
@@ -502,5 +501,20 @@ export const ENGLISH = {
'View Marketplace': 500,
'Welcome to': 501,
'sovereign computing': 502,
'french': 503,
'Customize the name appearing in your browser tab': 503,
'Manage': 504, // as in, administer
'Are you sure you want to delete this address?': 505, // this address referes to a domain or URL
'"Soft uninstall" will remove the service from StartOS but preserve its data.': 506,
'No saved providers': 507,
'Kiosk Mode': 508, // an OS mode that permits attaching a monitor to the computer
'Enabled': 509,
'Disable Kiosk Mode unless you need to attach a monitor': 510,
'Enable Kiosk Mode if you need to attach a monitor': 511,
'Kiosk Mode is unavailable on this device': 512,
'Enable': 513,
'Disable': 514,
'You are currently using a kiosk. Disabling Kiosk Mode will result in the kiosk disconnecting.': 515,
'Recommended': 516, // as in, we recommend this
'Are you sure you want to dismiss this task?': 517,
'Dismiss': 518, // as in, dismiss or delete a task
} as const

View File

@@ -45,7 +45,7 @@ export default {
42: 'Iniciando reinicio',
43: 'Estás usando la última versión de StartOS.',
44: '¡Actualizado!',
45: 'Notas de la versión',
45: 'notas de la versión',
46: 'Iniciar actualización',
47: 'Iniciando actualización',
48: 'Actualmente estás conectado a través de Tor. Si restableces el servicio Tor, perderás la conexión hasta que vuelva a estar en línea.',
@@ -60,7 +60,7 @@ export default {
57: 'Iniciando apagado',
58: 'Agregar',
59: 'Ok',
60: '¿Estás seguro de que deseas eliminar esta entrada?',
60: 'francés',
61: 'Este valor no se puede cambiar una vez establecido',
62: 'Continuar',
63: 'Haz clic o suelta el archivo aquí',
@@ -85,7 +85,7 @@ export default {
82: 'Métricas',
83: 'Registros',
84: 'Notificaciones',
85: 'Abrir interfaz',
85: 'Desinstalación forzada',
86: 'Mostrar QR',
87: 'Copiar URL',
88: 'Acciones',
@@ -230,9 +230,9 @@ export default {
227: 'Error desconocido',
228: 'Error',
229: '"Reconstruir contenedor" es una acción inofensiva que solo toma unos segundos. Probablemente resolverá este problema.',
230: '"Desinstalar servicio" es una acción peligrosa que eliminará el servicio de StartOS y borrará todos sus datos.',
230: '"Desinstalación forzada" es una acción peligrosa que eliminará el servicio de StartOS y borrará todos sus datos.',
231: 'Reconstruir contenedor',
232: 'Desinstalar servicio',
232: 'Desinstalación suave',
233: 'Ver mensaje completo',
234: 'Error del servicio',
235: 'Esperando resultado',
@@ -247,7 +247,6 @@ export default {
244: 'Alojamiento',
245: 'Instalando',
246: 'Ver abajo',
247: 'Controles',
248: 'No hay servicios instalados',
249: 'En ejecución',
250: 'Detenido',
@@ -414,12 +413,12 @@ export default {
411: 'Otras redes',
412: 'WiFi está deshabilitado',
413: 'No se detectó interfaz inalámbrica',
414: 'Habilitando WiFi',
415: 'Deshabilitando WiFi',
414: 'Habilitando',
415: 'Deshabilitando',
416: 'Conectando. Esto podría tardar un poco',
417: 'Reintentar',
418: 'Mostrar más',
419: 'Notas de la versión',
419: 'Ver detalles',
420: 'Ver listado',
421: 'Servicios que dependen de',
422: 'ya no funcionarán correctamente y podrían fallar.',
@@ -503,5 +502,20 @@ export default {
500: 'Ver Marketplace',
501: 'Bienvenido a',
502: 'computación soberana',
503: 'francés',
503: 'Personaliza el nombre que aparece en la pestaña de tu navegador',
504: 'Administrar',
505: '¿Estás seguro de que deseas eliminar esta dirección?',
506: '"Desinstalación suave" eliminará el servicio de StartOS pero conservará sus datos.',
507: 'No hay proveedores guardados',
508: 'Modo quiosco',
509: 'Activado',
510: 'Desactiva el modo quiosco a menos que necesites conectar un monitor',
511: 'Activa el modo quiosco si necesitas conectar un monitor',
512: 'El modo quiosco no está disponible en este dispositivo',
513: 'Activar',
514: 'Desactivar',
515: 'Actualmente estás utilizando un quiosco. Desactivar el modo quiosco provocará su desconexión.',
516: 'Recomendado',
517: '¿Estás seguro de que deseas descartar esta tarea?',
518: 'Descartar',
} satisfies i18n

View File

@@ -60,7 +60,7 @@ export default {
57: 'Arrêt initié',
58: 'Ajouter',
59: 'OK',
60: 'Voulez-vous vraiment supprimer cette entrée ?',
60: 'français',
61: 'Cette valeur ne peut plus être modifiée une fois définie',
62: 'Continuer',
63: 'Cliquez ou déposez le fichier ici',
@@ -85,7 +85,7 @@ export default {
82: 'Métriques',
83: 'Journaux',
84: 'Notifications',
85: 'Lancer linterface utilisateur',
85: 'Désinstallation forcée',
86: 'Afficher le QR',
87: 'Copier lURL',
88: 'Actions',
@@ -230,9 +230,9 @@ export default {
227: 'Erreur inconnue',
228: 'Erreur',
229:  Reconstruire le conteneur » est une action sans risque qui ne prend que quelques secondes. Cela résoudra probablement ce problème.',
230:  Désinstaller le service » est une action risquée qui supprimera le service de StartOS et effacera toutes ses données.',
230:  Désinstallation forcée » est une action risquée qui supprimera le service de StartOS et effacera toutes ses données.',
231: 'Reconstruire le conteneur',
232: 'Désinstaller le service',
232: 'Désinstallation douce',
233: 'Voir le message complet',
234: 'Erreur du service',
235: 'En attente du résultat',
@@ -247,7 +247,6 @@ export default {
244: 'Hébergement',
245: 'Installation',
246: 'Voir ci-dessous',
247: 'Contrôles',
248: 'Aucun service installé',
249: 'En fonctionnement',
250: 'Arrêté',
@@ -414,12 +413,12 @@ export default {
411: 'Autres réseaux',
412: 'Le WiFi est désactivé',
413: 'Aucune interface sans fil détectée',
414: 'Activation du WiFi',
415: 'Désactivation du WiFi',
414: 'Activation',
415: 'Désactivation',
416: 'Connexion en cours. Cela peut prendre un certain temps',
417: 'Réessayer',
418: 'Afficher plus',
419: 'Notes de version',
419: 'Voir les détails',
420: 'Voir la fiche',
421: 'Services dépendants de',
422: 'ne fonctionneront plus correctement et pourraient planter.',
@@ -503,5 +502,20 @@ export default {
500: 'Voir la bibliothèque de services',
501: 'Bienvenue sur',
502: 'informatique souveraine',
503: 'français',
503: 'Personnalisez le nom qui apparaît dans longlet de votre navigateur',
504: 'Gérer',
505: 'Êtes-vous sûr de vouloir supprimer cette adresse ?',
506:  Désinstallation douce » supprimera le service de StartOS tout en conservant ses données.',
507: 'Aucun fournisseur enregistré',
508: 'Mode kiosque',
509: 'Activé',
510: 'Désactivez le mode kiosque sauf si vous devez connecter un moniteur',
511: 'Activez le mode kiosque si vous devez connecter un moniteur',
512: 'Le mode kiosque nest pas disponible sur cet appareil',
513: 'Activer',
514: 'Désactiver',
515: 'Vous utilisez actuellement un kiosque. Désactiver le mode kiosque entraînera sa déconnexion.',
516: 'Recommandé',
517: 'Êtes-vous sûr de vouloir ignorer cette tâche ?',
518: 'Ignorer',
} satisfies i18n

View File

@@ -60,7 +60,7 @@ export default {
57: 'Rozpoczynanie wyłączania',
58: 'Dodaj',
59: 'OK',
60: 'Czy na pewno chcesz usunąć ten wpis?',
60: 'francuski',
61: 'Ta wartość nie może być zmieniona po jej ustawieniu',
62: 'Kontynuuj',
63: 'Kliknij lub upuść plik tutaj',
@@ -85,7 +85,7 @@ export default {
82: 'Monitorowanie',
83: 'Logi',
84: 'Powiadomienia',
85: 'Uruchom interfejs',
85: 'Twarde odinstalowanie',
86: 'Pokaż kod QR',
87: 'Kopiuj URL',
88: 'Akcje',
@@ -230,9 +230,9 @@ export default {
227: 'Nieznany błąd',
228: 'Błąd',
229: '„Odbuduj kontener” to bezpieczna akcja, która zajmuje tylko kilka sekund. Prawdopodobnie rozwiąże ten problem.',
230: '„Odinstaluj serwis” to niebezpieczna akcja, która usunie serwis ze StartOS i trwale usunie wszystkie jego dane.',
230: '„Twarde odinstalowanie” to niebezpieczna akcja, która usunie serwis ze StartOS i trwale usunie wszystkie jego dane.',
231: 'Odbuduj kontener',
232: 'Odinstaluj serwis',
232: 'Miękkie odinstalowanie',
233: 'Zobacz pełną wiadomość',
234: 'Błąd serwisu',
235: 'Oczekiwanie na wynik',
@@ -247,7 +247,6 @@ export default {
244: 'Hosting',
245: 'Instalowanie',
246: 'Zobacz poniżej',
247: 'Sterowanie',
248: 'Brak zainstalowanych serwisów',
249: 'Uruchomiony',
250: 'Zatrzymany',
@@ -414,12 +413,12 @@ export default {
411: 'Inne sieci',
412: 'WiFi jest wyłączone',
413: 'Nie wykryto interfejsu bezprzewodowego',
414: 'Włączanie WiFi',
415: 'Wyłączanie WiFi',
414: 'Włączanie',
415: 'Wyłączanie',
416: 'Łączenie. To może chwilę potrwać',
417: 'Ponów próbę',
418: 'Pokaż więcej',
419: 'Informacje o wydaniu',
419: 'Zobacz szczegóły',
420: 'Zobacz listę',
421: 'Serwisy zależne od',
422: 'przestaną działać poprawnie i mogą ulec awarii.',
@@ -503,5 +502,20 @@ export default {
500: 'Zobacz Rynek',
501: 'Witamy w',
502: 'suwerenne przetwarzanie',
503: 'francuski',
503: 'Dostosuj nazwę wyświetlaną na karcie przeglądarki',
504: 'Zarządzać',
505: 'Czy na pewno chcesz usunąć ten adres?',
506: '„Miękkie odinstalowanie” usunie usługę z StartOS, ale zachowa jej dane.',
507: 'Brak zapisanych dostawców',
508: 'Tryb kiosku',
509: 'Włączony',
510: 'Wyłącz tryb kiosku, chyba że potrzebujesz podłączyć monitor',
511: 'Włącz tryb kiosku, jeśli potrzebujesz podłączyć monitor',
512: 'Tryb kiosku jest niedostępny na tym urządzeniu',
513: 'Włącz',
514: 'Wyłącz',
515: 'Obecnie używasz kiosku. Wyłączenie trybu kiosku spowoduje jego rozłączenie.',
516: 'Zalecane',
517: 'Czy na pewno chcesz odrzucić to zadanie?',
518: 'Odrzuć',
} satisfies i18n

View File

@@ -11,8 +11,6 @@ import { I18N, i18nKey } from './i18n.providers'
export class i18nPipe implements PipeTransform {
private readonly i18n = inject(I18N)
// @TODO uncomment to make sure translations are present
// transform(englishKey: string | null | undefined): string | undefined {
transform(englishKey: i18nKey | null | undefined): string | undefined {
return englishKey
? this.i18n()?.[ENGLISH[englishKey as i18nKey]] || englishKey

View File

@@ -34,5 +34,11 @@ export class i18nService extends TuiLanguageSwitcherService {
}
}
export const languages = ['english', 'spanish', 'polish', 'german', 'french'] as const
export const languages = [
'english',
'spanish',
'polish',
'german',
'french',
] as const
export type Languages = (typeof languages)[number]

View File

@@ -12,13 +12,15 @@ export function formatProgress({ phases, overall }: T.FullProgress): {
p,
): p is {
name: string
progress: {
done: number
total: number | null
}
progress:
| false
| {
done: number
total: number | null
}
} => p.progress !== true && p.progress !== null,
)
.map(p => `<b>${p.name}</b>${getPhaseBytes(p.progress)}`)
.map(p => `<b>${p.name}</b>: (${getPhaseBytes(p.progress)})`)
.join(', '),
}
}
@@ -33,8 +35,13 @@ function getDecimal(progress: T.Progress): number {
}
}
function getPhaseBytes(progress: T.Progress): string {
return progress === true || !progress
? ''
: `: (${progress.done}/${progress.total})`
function getPhaseBytes(
progress:
| false
| {
done: number
total: number | null
},
): string {
return !progress ? 'unknown' : `${progress.done}/${progress.total}`
}

View File

@@ -277,6 +277,7 @@ body {
vertical-align: bottom;
animation: ellipsis-dot 1s infinite 0.3s;
animation-fill-mode: forwards;
text-align: left;
width: 1em;
}

View File

@@ -95,6 +95,24 @@
}
}
[tuiAppearance][data-appearance='primary-success'] {
color: var(--tui-text-primary-on-accent-1);
background: var(--tui-status-positive);
@include appearance-hover {
filter: brightness(1.2);
}
@include appearance-active {
filter: brightness(0.9);
}
@include appearance-disabled {
background: var(--tui-status-neutral);
color: #333;
}
}
tui-hint[data-appearance='onDark'] {
background: white !important;
color: #222 !important;

View File

@@ -48,6 +48,6 @@ export default class InitializingPage {
return caught$
}),
),
{ initialValue: { total: 0, message: '' } },
{ initialValue: { total: 0, message: 'waiting...' } },
)
}

View File

@@ -51,7 +51,6 @@ export class LoginPage {
} catch (e: any) {
// code 7 is for incorrect password
this.error = e.code === 7 ? 'Invalid password' : (e.message as i18nKey)
} finally {
loader.unsubscribe()
}
}

View File

@@ -73,20 +73,7 @@ export class FormArrayComponent {
}
removeAt(index: number) {
this.dialog
.openConfirm<boolean>({
label: 'Confirm',
size: 's',
data: {
content: 'Are you sure you want to delete this entry?',
yes: 'Delete',
no: 'Cancel',
},
})
.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.removeItem(index)
})
this.removeItem(index)
}
private removeItem(index: number) {

View File

@@ -29,7 +29,7 @@ import { ABOUT } from './about.component'
appearance=""
tuiHintDirection="bottom"
[tuiHint]="open ? '' : ('Start Menu' | i18n)"
[tuiHintShowDelay]="1000"
[tuiHintShowDelay]="750"
[tuiDropdown]="content"
[(tuiDropdownOpen)]="open"
[tuiDropdownMaxHeight]="9999"

View File

@@ -23,7 +23,7 @@ import { getMenu } from 'src/app/utils/system-utilities'
class="link"
routerLinkActive="link_active"
tuiHintDirection="bottom"
[tuiHintShowDelay]="1000"
[tuiHintShowDelay]="750"
[routerLink]="item.routerLink"
[class.link_system]="item.routerLink === '/portal/system'"
[tuiHint]="rla.isActive ? '' : (item.name | i18n)"

View File

@@ -15,6 +15,7 @@ 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({
standalone: true,
@@ -22,24 +23,30 @@ import { InterfaceComponent } from './interface.component'
template: `
<div class="desktop">
<ng-content />
@if (interface.serviceInterface().type === 'ui') {
<a
@if (interface.value().type === 'ui') {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[href]="actions()"
[disabled]="disabled()"
(click)="openUI()"
>
{{ 'Launch UI' | i18n }}
</a>
{{ 'Open' | i18n }}
</button>
}
<button tuiIconButton iconStart="@tui.qr-code" (click)="showQR()">
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.qr-code"
(click)="showQR()"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(actions())"
(click)="copyService.copy(href())"
>
{{ 'Copy URL' | i18n }}
</button>
@@ -55,27 +62,26 @@ import { InterfaceComponent } from './interface.component'
<ng-template #dropdown let-close>
<tui-data-list>
<tui-opt-group>
@if (interface.serviceInterface().type === 'ui') {
<a
tuiOption
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[href]="actions()"
>
{{ 'Launch UI' | i18n }}
</a>
<button tuiOption iconStart="@tui.qr-code" (click)="showQR()">
{{ 'Show QR' | i18n }}
</button>
@if (interface.value().type === 'ui') {
<button
tuiOption
iconStart="@tui.copy"
(click)="copyService.copy(actions()); close()"
iconStart="@tui.external-link"
[disabled]="disabled()"
(click)="openUI()"
>
{{ 'Copy URL' | i18n }}
{{ 'Open' | i18n }}
</button>
}
<button tuiOption iconStart="@tui.qr-code" (click)="showQR()">
{{ 'Show QR' | i18n }}
</button>
<button
tuiOption
iconStart="@tui.copy"
(click)="copyService.copy(href()); close()"
>
{{ 'Copy URL' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group><ng-content select="[tuiOption]" /></tui-opt-group>
</tui-data-list>
@@ -110,20 +116,27 @@ import { InterfaceComponent } from './interface.component'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceActionsComponent {
private readonly document = inject(DOCUMENT)
readonly isMobile = inject(TUI_IS_MOBILE)
readonly dialog = inject(DialogService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceComponent)
readonly actions = input.required<string>()
readonly href = input.required<string>()
readonly disabled = input.required<boolean>()
showQR() {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {
size: 'auto',
closeable: this.isMobile,
data: this.actions(),
data: this.href(),
})
.subscribe()
}
openUI() {
this.document.defaultView?.open(this.href(), '_blank', 'noreferrer')
}
}

View File

@@ -1,13 +1,13 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
@@ -59,22 +59,12 @@ type ClearnetForm = {
}}
<a
tuiLink
docsLink
href="/user-manual/connecting-remotely/clearnet.html"
target="_blank"
rel="noreferrer"
>
{{ 'Learn more' | i18n }}
</a>
</ng-template>
<button
tuiButton
[appearance]="isPublic() ? 'primary-destructive' : 'accent'"
[iconStart]="isPublic() ? '@tui.globe-lock' : '@tui.globe'"
[style.margin-inline-start]="'auto'"
(click)="toggle()"
>
{{ isPublic() ? ('Make private' | i18n) : ('Make public' | i18n) }}
</button>
@if (clearnet().length) {
<button tuiButton iconStart="@tui.plus" (click)="add()">
{{ 'Add' | i18n }}
@@ -86,18 +76,15 @@ type ClearnetForm = {
@for (address of clearnet(); track $index) {
<tr>
<td [style.width.rem]="12">
{{
interface.serviceInterface().addSsl
? (address.acme | acme)
: '-'
}}
{{ interface.value().addSsl ? (address.acme | acme) : '-' }}
</td>
<td>{{ address.url | mask }}</td>
<td [actions]="address.url">
<td actions [href]="address.url" [disabled]="!isRunning()">
@if (address.isDomain) {
<button
tuiButton
appearance="primary-destructive"
tuiIconButton
iconStart="@tui.trash"
appearance="flat-grayscale"
[style.margin-inline-end.rem]="0.5"
(click)="remove(address)"
>
@@ -141,6 +128,7 @@ type ClearnetForm = {
AcmePipe,
InterfaceActionsComponent,
i18nPipe,
DocsLinkDirective,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -152,9 +140,10 @@ export class InterfaceClearnetComponent {
private readonly api = inject(ApiService)
readonly interface = inject(InterfaceComponent)
readonly isPublic = computed(() => this.interface.serviceInterface().public)
readonly clearnet = input.required<readonly ClearnetAddress[]>()
readonly isRunning = input.required<boolean>()
readonly acme = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'acme')
@@ -165,7 +154,15 @@ export class InterfaceClearnetComponent {
async remove({ url }: ClearnetAddress) {
const confirm = await firstValueFrom(
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.openConfirm({
label: 'Confirm',
size: 's',
data: {
yes: 'Delete',
no: 'Cancel',
content: 'Are you sure you want to delete this address?',
},
})
.pipe(defaultIfEmpty(false)),
)
@@ -181,7 +178,7 @@ export class InterfaceClearnetComponent {
await this.api.pkgRemoveDomain({
...params,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverRemoveDomain(params)
@@ -195,33 +192,6 @@ export class InterfaceClearnetComponent {
}
}
async toggle() {
const loader = this.loader
.open(`Making ${this.isPublic() ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.interface.serviceInterface().addressInfo.internalPort,
public: !this.isPublic(),
}
try {
if (this.interface.packageId()) {
await this.api.pkgBindingSetPubic({
...params,
host: this.interface.serviceInterface().addressInfo.hostId,
package: this.interface.packageId(),
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async add() {
const domain = ISB.Value.text({
name: 'Domain',
@@ -250,9 +220,7 @@ export class InterfaceClearnetComponent {
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of(
this.interface.serviceInterface().addSsl
? { domain, acme }
: { domain },
this.interface.value().addSsl ? { domain, acme } : { domain },
),
),
buttons: [
@@ -281,7 +249,7 @@ export class InterfaceClearnetComponent {
await this.api.pkgAddDomain({
...params,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverAddDomain(params)

View File

@@ -1,17 +1,39 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core'
import { InterfaceClearnetComponent } from 'src/app/routes/portal/components/interfaces/clearnet.component'
import { InterfaceLocalComponent } from 'src/app/routes/portal/components/interfaces/local.component'
import { InterfaceTorComponent } from 'src/app/routes/portal/components/interfaces/tor.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MappedServiceInterface } from './interface.utils'
@Component({
standalone: true,
selector: 'app-interface',
template: `
<section [clearnet]="serviceInterface().addresses.clearnet"></section>
<section [tor]="serviceInterface().addresses.tor"></section>
<section [local]="serviceInterface().addresses.local"></section>
<button
tuiButton
size="s"
[appearance]="value().public ? 'primary-destructive' : 'primary-success'"
[iconStart]="value().public ? '@tui.globe-lock' : '@tui.globe'"
(click)="toggle()"
>
{{ value().public ? ('Make private' | i18n) : ('Make public' | i18n) }}
</button>
<section
[clearnet]="value().addresses.clearnet"
[isRunning]="isRunning()"
></section>
<section [tor]="value().addresses.tor" [isRunning]="isRunning()"></section>
<section
[local]="value().addresses.local"
[isRunning]="isRunning()"
></section>
`,
styles: `
:host {
@@ -19,6 +41,12 @@ import { MappedServiceInterface } from './interface.utils'
display: flex;
flex-direction: column;
gap: 1rem;
color: var(--tui-text-secondary);
font: var(--tui-font-text-l);
}
button {
margin: -0.5rem auto 0 0;
}
`,
providers: [tuiButtonOptionsProvider({ size: 'xs' })],
@@ -27,9 +55,43 @@ import { MappedServiceInterface } from './interface.utils'
InterfaceClearnetComponent,
InterfaceTorComponent,
InterfaceLocalComponent,
TuiButton,
i18nPipe,
],
})
export class InterfaceComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly packageId = input('')
readonly serviceInterface = input.required<MappedServiceInterface>()
readonly value = input.required<MappedServiceInterface>()
readonly isRunning = input.required<boolean>()
async toggle() {
const loader = this.loader
.open(`Making ${this.value().public ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.value().addressInfo.internalPort,
public: !this.value().public,
}
try {
if (this.packageId()) {
await this.api.pkgBindingSetPubic({
...params,
host: this.value().addressInfo.hostId,
package: this.packageId(),
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -29,7 +29,7 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
<tr>
<td [style.width.rem]="12">{{ address.nid }}</td>
<td>{{ address.url | mask }}</td>
<td [actions]="address.url"></td>
<td actions [href]="address.url" [disabled]="!isRunning()"></td>
</tr>
}
</table>
@@ -49,4 +49,5 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
})
export class InterfaceLocalComponent {
readonly local = input.required<readonly LocalAddress[]>()
readonly isRunning = input.required<boolean>()
}

View File

@@ -9,7 +9,7 @@ export class MaskPipe implements PipeTransform {
private readonly interface = inject(InterfaceComponent)
transform(value: string): string {
return this.interface.serviceInterface().masked
return this.interface.value().masked
? '●'.repeat(Math.min(64, value.length))
: value
}

View File

@@ -9,13 +9,16 @@ import { TuiBadge } from '@taiga-ui/kit'
<tui-badge
size="l"
[iconStart]="public() ? '@tui.globe' : '@tui.lock'"
[style.vertical-align.rem]="-0.125"
[style.margin]="'0 0.25rem -0.25rem'"
[appearance]="public() ? 'positive' : 'negative'"
>
{{ public() ? ('Public' | i18n) : ('Private' | i18n) }}
</tui-badge>
`,
styles: `
:host {
display: inline-flex;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiBadge, i18nPipe],
})

View File

@@ -76,11 +76,11 @@ type OnionForm = {
{{ address.url | mask }}
</div>
</td>
<td [actions]="address.url">
<td actions [href]="address.url" [disabled]="!isRunning()">
<button
tuiButton
appearance="primary-destructive"
[style.margin-inline-end.rem]="0.5"
tuiIconButton
iconStart="@tui.trash"
appearance="flat-grayscale"
(click)="remove(address)"
>
{{ 'Delete' | i18n }}
@@ -141,11 +141,20 @@ export class InterfaceTorComponent {
private readonly i18n = inject(i18nPipe)
readonly tor = input.required<readonly TorAddress[]>()
readonly isRunning = input.required<boolean>()
async remove({ url }: TorAddress) {
const confirm = await firstValueFrom(
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.openConfirm({
label: 'Confirm',
size: 's',
data: {
yes: 'Delete',
no: 'Cancel',
content: 'Are you sure you want to delete this address?',
},
})
.pipe(defaultIfEmpty(false)),
)
@@ -161,7 +170,7 @@ export class InterfaceTorComponent {
await this.api.pkgRemoveOnion({
...params,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverRemoveOnion(params)
@@ -215,7 +224,7 @@ export class InterfaceTorComponent {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })

View File

@@ -62,6 +62,13 @@ import { HeaderComponent } from './components/header/header.component'
flex-direction: column;
// @TODO Theme
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
&::before {
content: '';
position: fixed;
inset: 0;
backdrop-filter: blur(0.5rem);
}
}
main {

View File

@@ -30,7 +30,7 @@ const ROUTES: Routes = [
{
title: titleResolver,
path: 'logs',
loadComponent: () => import('./routes/logs/logs.component'),
loadChildren: () => import('./routes/logs/logs.routes'),
data: toNavigationItem('/portal/logs'),
},
{

View File

@@ -0,0 +1,64 @@
import {
ChangeDetectionStrategy,
Component,
input,
ViewEncapsulation,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { TitleDirective } from 'src/app/services/title.service'
@Component({
standalone: true,
selector: 'logs-header',
template: `
<ng-container *title>
<a tuiIconButton size="m" iconStart="@tui.arrow-left" routerLink="..">
{{ 'Back' | i18n }}
</a>
{{ title() }}
</ng-container>
<hgroup tuiTitle>
<h3>{{ title() }}</h3>
<p tuiSubtitle><ng-content /></p>
</hgroup>
<aside tuiAccessories>
<a
tuiIconButton
appearance="secondary-grayscale"
iconStart="@tui.x"
size="s"
routerLink=".."
[style.border-radius.%]="100"
>
{{ 'Close' | i18n }}
</a>
</aside>
`,
styles: `
logs-header[tuiHeader] {
margin-block-end: 1rem;
+ logs {
height: calc(100% - 5rem);
}
tui-root._mobile & {
display: none;
+ logs {
height: 100%;
}
}
}
`,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiTitle, i18nPipe, RouterLink, TitleDirective],
hostDirectives: [TuiHeader],
})
export class LogsHeaderComponent {
readonly title = input<string | undefined>()
}

View File

@@ -1,202 +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>
}
}
`,
standalone: true,
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: 700;
}
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

@@ -0,0 +1,22 @@
import { Routes } from '@angular/router'
export const ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./routes/outlet.component'),
},
{
path: 'kernel',
loadComponent: () => import('./routes/kernel.component'),
},
{
path: 'os',
loadComponent: () => import('./routes/os.component'),
},
{
path: 'tor',
loadComponent: () => import('./routes/tor.component'),
},
]
export default ROUTES

View File

@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
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 { LogsHeaderComponent } from '../components/header.component'
@Component({
standalone: true,
template: `
<logs-header [title]="'Kernel Logs' | i18n">
{{ 'Diagnostics for drivers and other kernel processes' | i18n }}
</logs-header>
<logs context="kernel" [followLogs]="follow" [fetchLogs]="fetch" />
`,
styles: `
:host {
padding: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
host: { class: 'g-page' },
})
export default class SystemKernelComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
this.api.followKernelLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
this.api.getKernelLogs(params)
log = {
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
}
}

View File

@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { LogsHeaderComponent } from 'src/app/routes/portal/routes/logs/components/header.component'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
standalone: true,
template: `
<logs-header [title]="'OS Logs' | i18n">
{{ 'Raw, unfiltered operating system logs' | i18n }}
</logs-header>
<logs context="os" [followLogs]="follow" [fetchLogs]="fetch" />
`,
styles: `
:host {
padding: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
host: { class: 'g-page' },
})
export default class SystemOSComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
this.api.followServerLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
this.api.getServerLogs(params)
log = {
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
}
}

View File

@@ -0,0 +1,96 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { RouterLink } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { TuiAppearance, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCardMedium } from '@taiga-ui/layout'
import { TitleDirective } from 'src/app/services/title.service'
@Component({
template: `
<ng-container *title>{{ 'Logs' | i18n }}</ng-container>
@for (log of logs; track $index) {
<a tuiCardMedium tuiAppearance="neutral" [routerLink]="log.link">
<tui-icon [icon]="log.icon" />
<span tuiTitle>
{{ log.title | i18n }}
<span tuiSubtitle>{{ log.subtitle | i18n }}</span>
</span>
<tui-icon icon="@tui.chevron-right" />
</a>
}
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'g-page' },
styles: [
`
:host {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
}
[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;
[tuiCardMedium] {
width: 100%;
height: auto;
gap: 1rem;
}
}
`,
],
imports: [
RouterLink,
TitleDirective,
TuiTitle,
TuiCardMedium,
TuiIcon,
TuiAppearance,
i18nPipe,
],
})
export default class SystemLogsComponent {
readonly logs = [
{
link: 'os',
title: 'OS Logs',
subtitle: 'Raw, unfiltered operating system logs',
icon: '@tui.square-dashed-bottom-code',
},
{
link: 'kernel',
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
},
{
link: 'tor',
title: 'Tor Logs',
subtitle: 'Diagnostic logs for the Tor daemon on StartOS',
icon: '@tui.globe',
},
] as const
}

View File

@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { LogsHeaderComponent } from 'src/app/routes/portal/routes/logs/components/header.component'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
standalone: true,
template: `
<logs-header [title]="'Tor Logs' | i18n">
{{ 'Diagnostic logs for the Tor daemon on StartOS' | i18n }}
</logs-header>
<logs context="tor" [followLogs]="follow" [fetchLogs]="fetch" />
`,
styles: `
:host {
padding: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
host: { class: 'g-page' },
})
export default class SystemOSComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
this.api.followServerLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
this.api.getServerLogs(params)
log = {
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
}
}

View File

@@ -64,8 +64,15 @@ import { StorageService } from 'src/app/services/storage.service'
overflow: hidden;
padding: 0;
background: rgb(55 58 63 / 90%)
url('/assets/img/background_marketplace.png') no-repeat top right;
url('/assets/img/background_marketplace.jpg') no-repeat top right;
background-size: cover;
&::before {
content: '';
position: absolute;
inset: 0;
backdrop-filter: blur(2rem);
}
}
.marketplace-content {

View File

@@ -20,7 +20,6 @@ import {
import {
DialogService,
Exver,
i18nKey,
i18nPipe,
MARKDOWN,
SharedPipesModule,
@@ -34,6 +33,7 @@ import {
map,
startWith,
switchMap,
tap,
} from 'rxjs'
import { MarketplaceService } from 'src/app/services/marketplace.service'
@@ -59,10 +59,9 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
<marketplace-additional-item
(click)="selectVersion(pkg, version)"
[data]="('Click to view all versions' | i18n) || ''"
[icon]="versions.length > 1 ? '@tui.chevron-right' : ''"
icon="@tui.chevron-right"
label="All versions"
class="versions"
[class.versions_empty]="versions.length < 2"
/>
<ng-template
#version
@@ -81,7 +80,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
<button
tuiButton
appearance="secondary"
(click)="completeWith(data.value)"
(click)="completeWith(data.version)"
>
{{ 'Ok' | i18n }}
</button>
@@ -91,7 +90,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
</marketplace-additional>
</div>
} @else {
<tui-loader class="loading" textContent="Loading" />
<tui-loader textContent="Loading" [style.height.%]="100" />
}
</div>
`,
@@ -114,7 +113,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
}
.listing {
font-size: 0.9rem;
font-size: 0.8rem;
// @TODO theme
color: #8059e5;
font-weight: 600;
@@ -139,16 +138,6 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
::ng-deep label {
cursor: pointer;
}
&_empty {
pointer-events: none;
}
}
.loading {
min-width: 30rem;
height: 100%;
place-self: center;
}
marketplace-additional {
@@ -254,6 +243,6 @@ export class MarketplacePreviewComponent {
data: { version },
})
.pipe(filter(Boolean))
.subscribe(version => this.version$.next(version))
.subscribe(selected => this.version$.next(selected))
}
}

View File

@@ -49,7 +49,7 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'
</linearGradient>
</defs>
</svg>
<b>{{ value() || '-' }}</b>
<b>{{ value() ? value() + ' C°' : 'N/A' }}</b>
`,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';

View File

@@ -55,7 +55,11 @@ import { i18nPipe } from '@start9labs/shared'
}
@if (notificationItem.code === 1 || notificationItem.code === 2) {
<button tuiLink (click)="service.viewModal(notificationItem)">
{{ 'View report' | i18n }}
{{
notificationItem.code === 1
? ('View report' | i18n)
: ('View details' | i18n)
}}
</button>
}
</td>
@@ -66,7 +70,7 @@ import { i18nPipe } from '@start9labs/shared'
'[class._new]': '!notificationItem.read',
},
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
:host {
grid-template-columns: 1fr;
@@ -90,8 +94,16 @@ import { i18nPipe } from '@start9labs/shared'
}
:host-context(tui-root._mobile) {
gap: 0.5rem;
padding: 0.75rem 1rem !important;
.checkbox {
@include fullsize();
@include taiga.fullsize();
@include taiga.transition(box-shadow);
&:has(:checked) {
box-shadow: inset 0.25rem 0 var(--tui-background-accent-1);
}
}
.date {
@@ -103,8 +115,7 @@ import { i18nPipe } from '@start9labs/shared'
font-weight: bold;
font-size: 1.2em;
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.5rem;
}
.service:not(:has(a)) {

View File

@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
@@ -66,7 +67,7 @@ import { NotificationsTableComponent } from './table.component'
i18nPipe,
],
})
export default class NotificationsComponent {
export default class NotificationsComponent implements OnInit {
private readonly router = inject(Router)
private readonly route = inject(ActivatedRoute)
@@ -74,16 +75,19 @@ export default class NotificationsComponent {
readonly api = inject(ApiService)
readonly errorService = inject(ErrorService)
readonly notifications = signal<ServerNotifications | undefined>(undefined)
readonly toast = this.route.queryParams.subscribe(params => {
this.router.navigate([], { relativeTo: this.route, queryParams: {} })
if (isEmptyObject(params)) {
this.getMore({})
}
})
open = false
ngOnInit() {
this.route.queryParams.subscribe(params => {
this.router.navigate([], { relativeTo: this.route, queryParams: {} })
if (isEmptyObject(params)) {
this.getMore({})
}
})
}
async getMore(params: RR.GetNotificationsReq) {
try {
this.notifications.set(undefined)

View File

@@ -31,7 +31,7 @@ import { i18nPipe } from '@start9labs/shared'
/>
</th>
<th [style.min-width.rem]="12">{{ 'Date' | i18n }}</th>
<th [style.min-width.rem]="12">{{ 'Title' | i18n }}</th>
<th [style.min-width.rem]="14">{{ 'Title' | i18n }}</th>
<th [style.min-width.rem]="8">{{ 'Service' | i18n }}</th>
<th>{{ 'Message' | i18n }}</th>
</tr>
@@ -71,9 +71,13 @@ import { i18nPipe } from '@start9labs/shared'
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
:host-context(tui-root._mobile) input {
@include fullsize();
opacity: 0;
:host-context(tui-root._mobile) {
margin: 0 -1rem;
input {
@include fullsize();
opacity: 0;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -14,7 +14,7 @@ interface ActionItem {
template: `
<div tuiTitle>
<strong>{{ action.name }}</strong>
<div tuiSubtitle>{{ action.description }}</div>
<div tuiSubtitle [innerHTML]="action.description"></div>
@if (disabled) {
<div tuiSubtitle class="g-warning">{{ disabled }}</div>
}

View File

@@ -27,7 +27,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
/>
</tui-avatar>
<span tuiTitle>
{{ d.value.title }}
{{ d.value.title || d.key }}
@if (getError(d.key); as error) {
<span tuiSubtitle class="g-warning">{{ error | i18n }}</span>
} @else {

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiLineClamp, TuiTooltip } from '@taiga-ui/kit'
import { TuiTooltip } from '@taiga-ui/kit'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data'
@@ -15,12 +15,9 @@ import { getManifest } from 'src/app/utils/get-package-data'
standalone: true,
selector: 'service-error',
template: `
<header>{{ 'Error' | i18n }}</header>
<tui-line-clamp
[linesLimit]="2"
[content]="error?.message"
(overflownChange)="overflow = $event"
/>
<header>{{ 'Service Launch Error' | i18n }}</header>
<p class="error-message">{{ error?.message }}</p>
<p>{{ error?.debug }}</p>
<h4>
{{ 'Actions' | i18n }}
<tui-icon [tuiTooltip]="hint" />
@@ -34,7 +31,13 @@ import { getManifest } from 'src/app/utils/get-package-data'
</p>
<p>
{{
'"Uninstall service" is a dangerous action that will remove the service from StartOS and wipe all its data.'
'"Soft uninstall" will remove the service from StartOS but preserve its data.'
| i18n
}}
</p>
<p>
{{
'"Hard uninstall" is a dangerous action that will remove the service from StartOS and wipe all its data.'
| i18n
}}
</p>
@@ -43,8 +46,11 @@ import { getManifest } from 'src/app/utils/get-package-data'
<button tuiButton (click)="rebuild()">
{{ 'Rebuild container' | i18n }}
</button>
<button tuiButton appearance="negative" (click)="uninstall()">
{{ 'Uninstall service' | i18n }}
<button tuiButton appearance="warning" (click)="uninstall()">
{{ 'Soft uninstall' | i18n }}
</button>
<button tuiButton appearance="negative" (click)="uninstall(false)">
{{ 'Hard uninstall' | i18n }}
</button>
@if (overflow) {
<button tuiButton appearance="secondary-grayscale" (click)="show()">
@@ -55,23 +61,21 @@ import { getManifest } from 'src/app/utils/get-package-data'
`,
styles: `
:host {
grid-column: span 4;
grid-column: span 5;
}
header {
--tui-background-neutral-1: var(--tui-status-negative-pale);
}
tui-line-clamp {
pointer-events: none;
margin: 1rem 0;
.error-message {
font-size: 1.5rem;
color: var(--tui-status-negative);
margin-bottom: 0;
}
h4 {
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--tui-font-text-m);
font-weight: bold;
color: var(--tui-text-secondary);
@@ -80,7 +84,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiIcon, TuiTooltip, TuiLineClamp, i18nPipe],
imports: [TuiButton, TuiIcon, TuiTooltip, i18nPipe],
})
export class ServiceErrorComponent {
private readonly dialog = inject(DialogService)
@@ -99,8 +103,8 @@ export class ServiceErrorComponent {
this.service.rebuild(getManifest(this.pkg).id)
}
uninstall() {
this.service.uninstall(getManifest(this.pkg))
uninstall(soft = true) {
this.service.uninstall(getManifest(this.pkg), { force: true, soft })
}
show() {

View File

@@ -97,7 +97,7 @@ export class ServiceHealthCheckComponent {
case 'starting':
return this.i18n.transform('Starting')!
case 'success':
return `${this.i18n.transform('Success')}: ${this.healthCheck.message}`
return `${this.i18n.transform('Success')}: ${this.healthCheck.message || 'health check passing'}`
case 'loading':
case 'failure':
return this.healthCheck.message

View File

@@ -1,3 +1,4 @@
import { DOCUMENT } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -5,9 +6,8 @@ import {
Input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiIcon, TuiLink } from '@taiga-ui/core'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@@ -16,16 +16,11 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
selector: 'tr[serviceInterface]',
template: `
<td>
<a tuiLink [routerLink]="info.routerLink">
<strong>{{ info.name }}</strong>
</a>
<strong>{{ info.name }}</strong>
</td>
<td>
<tui-badge size="m" [appearance]="appearance">{{ info.type }}</tui-badge>
</td>
<td class="g-secondary" [style.grid-area]="'2 / span 4'">
{{ info.description }}
</td>
<td [style.text-align]="'center'">
@if (info.public) {
<tui-icon class="g-positive" icon="@tui.globe" />
@@ -33,73 +28,70 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
<tui-icon class="g-negative" icon="@tui.lock" />
}
</td>
<td [style.grid-area]="'span 2'">
<td class="g-secondary" [style.grid-area]="'2 / span 4'">
{{ info.description }}
</td>
<td>
@if (info.type === 'ui') {
<a
<button
tuiIconButton
appearance="action"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
size="s"
[style.border-radius.%]="100"
[attr.href]="href"
(click.stop)="(0)"
>
{{ 'Open' | i18n }}
</a>
appearance="flat-grayscale"
[disabled]="disabled"
(click)="openUI()"
></button>
}
<a
tuiIconButton
iconStart="@tui.settings"
appearance="flat-grayscale"
[routerLink]="info.routerLink"
></a>
</td>
`,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
cursor: pointer;
clip-path: inset(0 round var(--tui-radius-m));
@include transition(background);
}
[tuiLink] {
background: transparent;
}
@media ($tui-mouse) {
:host:hover {
background: var(--tui-background-neutral-1);
}
}
strong {
white-space: nowrap;
}
tui-badge {
text-transform: uppercase;
font-weight: bold;
}
tui-icon {
font-size: 1rem;
}
td:last-child {
grid-area: 3 / span 4;
white-space: nowrap;
text-align: right;
flex-direction: row-reverse;
justify-content: flex-end;
gap: 0.5rem;
}
:host-context(tui-root._mobile) {
display: grid;
grid-template-columns: repeat(3, min-content) 1fr 2rem;
grid-template-columns: repeat(3, min-content) 1fr;
align-items: center;
padding: 1rem 0.5rem;
gap: 0.5rem;
td {
display: flex;
padding: 0;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiButton, TuiBadge, TuiLink, TuiIcon, RouterLink, i18nPipe],
imports: [TuiButton, TuiBadge, TuiIcon, RouterLink],
})
export class ServiceInterfaceComponent {
export class ServiceInterfaceItemComponent {
private readonly config = inject(ConfigService)
private readonly document = inject(DOCUMENT)
@Input({ required: true })
info!: T.ServiceInterface & {
@@ -116,17 +108,19 @@ export class ServiceInterfaceComponent {
get appearance(): string {
switch (this.info.type) {
case 'ui':
return 'primary'
return 'positive'
case 'api':
return 'accent'
return 'info'
case 'p2p':
return 'primary-grayscale'
return 'negative'
}
}
get href(): string | null {
return this.disabled
? null
: this.config.launchableAddress(this.info, this.pkg.hosts)
get href() {
return this.config.launchableAddress(this.info, this.pkg.hosts)
}
openUI() {
this.document.defaultView?.open(this.href, '_blank', 'noreferrer')
}
}

View File

@@ -5,13 +5,12 @@ import {
inject,
input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { TuiTable } from '@taiga-ui/addon-table'
import { tuiDefaultSort } from '@taiga-ui/cdk'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getAddresses } from '../../../components/interfaces/interface.utils'
import { ServiceInterfaceComponent } from './interface.component'
import { ServiceInterfaceItemComponent } from './interface-item.component'
import { i18nPipe } from '@start9labs/shared'
@Component({
@@ -24,8 +23,8 @@ import { i18nPipe } from '@start9labs/shared'
<tr>
<th tuiTh>{{ 'Name' | i18n }}</th>
<th tuiTh>{{ 'Type' | i18n }}</th>
<th tuiTh [style.text-align]="'center'">{{ 'Hosting' | i18n }}</th>
<th tuiTh>{{ 'Description' | i18n }}</th>
<th tuiTh>{{ 'Hosting' | i18n }}</th>
<th tuiTh></th>
</tr>
</thead>
@@ -33,7 +32,6 @@ import { i18nPipe } from '@start9labs/shared'
@for (info of interfaces(); track $index) {
<tr
serviceInterface
[routerLink]="info.routerLink"
[info]="info"
[pkg]="pkg()"
[disabled]="disabled()"
@@ -44,12 +42,12 @@ import { i18nPipe } from '@start9labs/shared'
`,
styles: `
:host {
grid-column: span 4;
grid-column: span 6;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceInterfaceComponent, TuiTable, RouterLink, i18nPipe],
imports: [ServiceInterfaceItemComponent, TuiTable, i18nPipe],
})
export class ServiceInterfacesComponent {
private readonly config = inject(ConfigService)

View File

@@ -45,6 +45,7 @@ import {
`
:host {
grid-column: span 2;
min-height: 12rem;
}
h3 {
@@ -77,6 +78,10 @@ import {
}
:host-context(tui-root._mobile) {
:host {
min-height: 0;
}
div {
display: grid;
grid-template-columns: 1fr max-content;

View File

@@ -5,56 +5,84 @@ import {
inject,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit'
import { TuiAvatar, TuiFade } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
@Component({
standalone: true,
selector: 'tr[actionRequest]',
selector: 'tr[task]',
template: `
<td>
<td tuiFade>
<tui-avatar size="xs"><img [src]="pkg()?.icon" alt="" /></tui-avatar>
<span>{{ title() }}</span>
<span>{{ pkgTitle() }}</span>
</td>
<td>
@if (actionRequest().severity === 'critical') {
{{ pkg()?.actions?.[task().actionId]?.name }}
</td>
<td>
@if (task().severity === 'critical') {
<strong [style.color]="'var(--tui-status-warning)'">
{{ 'Required' | i18n }}
</strong>
} @else {
} @else if (task().severity === 'important') {
<strong [style.color]="'var(--tui-status-info)'">
{{ 'Recommended' | i18n }}
</strong>
} @else {
<strong>
{{ 'Optional' | i18n }}
</strong>
}
</td>
<td
[style.color]="'var(--tui-text-secondary)'"
[style.grid-area]="'2 / span 2'"
[style.grid-area]="'2 / span 4'"
>
{{ actionRequest().reason || ('No reason provided' | i18n) }}
{{ task().reason || ('No reason provided' | i18n) }}
</td>
<td>
<button tuiButton (click)="handle()">
{{ pkg()?.actions?.[actionRequest().actionId]?.name }}
</button>
@if (task().severity !== 'critical') {
<button
tuiIconButton
iconStart="@tui.trash"
appearance="flat-grayscale"
(click)="dismiss()"
></button>
}
<button
tuiIconButton
iconStart="@tui.play"
appearance="flat-grayscale"
(click)="handle()"
></button>
</td>
`,
styles: `
td:first-child {
white-space: nowrap;
max-width: 10rem;
max-width: 15rem;
overflow: hidden;
text-overflow: ellipsis;
}
td:last-child {
grid-area: 3 / span 4;
white-space: nowrap;
text-align: right;
grid-area: span 2;
flex-direction: row-reverse;
justify-content: flex-end;
gap: 0.5rem;
}
span {
@@ -64,32 +92,66 @@ import { getManifest } from 'src/app/utils/get-package-data'
:host-context(tui-root._mobile) {
display: grid;
grid-template-columns: min-content 1fr min-content;
align-items: center;
padding: 1rem 0.5rem;
padding: 1rem 0rem 1rem 0.5rem;
gap: 0.5rem;
td {
display: flex;
align-items: center;
padding: 0;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiAvatar, i18nPipe],
imports: [TuiButton, TuiAvatar, i18nPipe, TuiFade],
})
export class ServiceTaskComponent {
private readonly actionService = inject(ActionService)
private readonly dialog = inject(DialogService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
readonly actionRequest = input.required<T.Task>()
readonly task = input.required<T.Task & { replayId: string }>()
readonly services = input.required<Record<string, PackageDataEntry>>()
readonly pkg = computed(() => this.services()[this.actionRequest().packageId])
readonly title = computed((pkg = this.pkg()) => pkg && getManifest(pkg).title)
readonly pkg = computed(() => this.services()[this.task().packageId])
readonly pkgTitle = computed(
(pkg = this.pkg()) => pkg && getManifest(pkg).title,
)
async dismiss() {
this.dialog
.openConfirm<boolean>({
label: 'Confirm',
size: 's',
data: {
content: 'Are you sure you want to dismiss this task?',
yes: 'Dismiss',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open().subscribe()
try {
await this.api.clearTask({
packageId: this.task().packageId,
replayId: this.task().replayId,
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
async handle() {
const title = this.title()
const title = this.pkgTitle()
const pkg = this.pkg()
const metadata = pkg?.actions[this.actionRequest().actionId]
const metadata = pkg?.actions[this.task().actionId]
if (!title || !pkg || !metadata) {
return
@@ -97,16 +159,16 @@ export class ServiceTaskComponent {
this.actionService.present({
pkgInfo: {
id: this.actionRequest().packageId,
id: this.task().packageId,
title,
mainStatus: pkg.status.main,
icon: pkg.icon,
},
actionInfo: {
id: this.actionRequest().actionId,
id: this.task().actionId,
metadata,
},
requestInfo: this.actionRequest(),
requestInfo: this.task(),
})
}
}

View File

@@ -19,18 +19,19 @@ import { i18nPipe } from '@start9labs/shared'
<thead>
<tr>
<th tuiTh>{{ 'Service' | i18n }}</th>
<th tuiTh>{{ 'Type' | i18n }}</th>
<th tuiTh>{{ 'Action' }}</th>
<th tuiTh>{{ 'Severity' }}</th>
<th tuiTh>{{ 'Description' | i18n }}</th>
<th tuiTh></th>
</tr>
</thead>
<tbody>
@for (item of requests(); track $index) {
<tr [actionRequest]="item.task" [services]="services()"></tr>
@for (item of tasks(); track $index) {
<tr [task]="item.task" [services]="services()"></tr>
}
</tbody>
</table>
@if (!requests().length) {
@if (!tasks().length) {
<app-placeholder icon="@tui.list-checks">
{{ 'All tasks complete' | i18n }}
</app-placeholder>
@@ -50,8 +51,12 @@ export class ServiceTasksComponent {
readonly pkg = input.required<PackageDataEntry>()
readonly services = input.required<Record<string, PackageDataEntry>>()
readonly requests = computed(() =>
Object.values(this.pkg().tasks)
readonly tasks = computed(() =>
Object.entries(this.pkg().tasks)
.map(([replayId, entry]) => ({
...entry,
task: { ...entry.task, replayId },
}))
.filter(
t =>
this.services()[t.task.packageId]?.actions[t.task.actionId] &&

View File

@@ -0,0 +1,90 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { map, timer } from 'rxjs'
import { distinctUntilChanged } from 'rxjs/operators'
@Component({
selector: 'service-uptime',
template: `
<header>{{ 'Uptime' | i18n }}</header>
<section>
@if (uptime$ | async; as time) {
<div>
<label>{{ time.days }}</label>
{{ 'Days' | i18n }}
</div>
<div>
<label>{{ time.hours }}</label>
{{ 'Hours' | i18n }}
</div>
<div>
<label>{{ time.minutes }}</label>
{{ 'Minutes' | i18n }}
</div>
<div>
<label>{{ time.seconds }}</label>
{{ 'Seconds' | i18n }}
</div>
}
</section>
`,
styles: [
`
:host {
grid-column: span 4;
}
h3 {
font: var(--tui-font-heading-4);
font-weight: normal;
margin: 0;
text-align: center;
}
section {
height: 100%;
max-width: 100%;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
place-content: center;
margin: auto;
padding: 1rem 0;
text-align: center;
text-transform: uppercase;
color: var(--tui-text-secondary);
font: var(--tui-font-text-ui-xs);
}
label {
display: block;
font-size: min(6vw, 2.5rem);
margin: 1rem 0;
color: var(--tui-text-primary);
}
`,
],
host: { class: 'g-card' },
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [i18nPipe, AsyncPipe],
})
export class ServiceUptimeComponent {
protected readonly uptime$ = timer(0, 1000).pipe(
map(() =>
this.started()
? Math.max(Date.now() - new Date(this.started()).getTime(), 0)
: 0,
),
distinctUntilChanged(),
map(delta => ({
seconds: Math.floor(delta / 1000) % 60,
minutes: Math.floor(delta / (1000 * 60)) % 60,
hours: Math.floor(delta / (1000 * 60 * 60)) % 24,
days: Math.floor(delta / (1000 * 60 * 60 * 24)),
})),
)
readonly started = input('')
}

View File

@@ -14,7 +14,7 @@ import { DepErrorService } from 'src/app/services/dep-error.service'
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.component'
import { UILaunchComponent } from './ui-launch.component'
import { i18nPipe } from '@start9labs/shared'
const RUNNING = ['running', 'starting', 'restarting']
@@ -23,6 +23,7 @@ const RUNNING = ['running', 'starting', 'restarting']
standalone: true,
selector: 'fieldset[appControls]',
template: `
<app-ui-launch [pkg]="pkg()" />
@if (running()) {
<button
tuiIconButton
@@ -42,8 +43,6 @@ const RUNNING = ['running', 'starting', 'restarting']
{{ 'Start' | i18n }}
</button>
}
<app-ui-launch [pkg]="pkg()" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, UILaunchComponent, TuiLet, AsyncPipe, i18nPipe],
@@ -53,6 +52,7 @@ const RUNNING = ['running', 'starting', 'restarting']
padding: 0;
border: none;
cursor: default;
text-align: right;
}
:host-context(tui-root._mobile) {

View File

@@ -35,9 +35,7 @@ import { i18nPipe } from '@start9labs/shared'
<th tuiTh [requiredSort]="true" [sorter]="status">
{{ 'Status' | i18n }}
</th>
<th [style.width.rem]="8" [style.text-indent.rem]="1.5">
{{ 'Controls' | i18n }}
</th>
<th [style.width.rem]="8" [style.text-indent.rem]="1.5"></th>
</tr>
</thead>
<tbody>

View File

@@ -57,7 +57,8 @@ export class StatusComponent {
private readonly i18n = inject(i18nPipe)
get healthy(): boolean {
return !this.hasDepErrors && this.getStatus(this.pkg).health !== 'failure'
const { primary, health } = this.getStatus(this.pkg)
return !this.hasDepErrors && primary !== 'error' && health !== 'failure'
}
get loading(): boolean {
@@ -66,7 +67,7 @@ export class StatusComponent {
@tuiPure
getStatus(pkg: PackageDataEntry) {
return renderPkgStatus(pkg, {})
return renderPkgStatus(pkg)
}
get status(): i18nKey {
@@ -95,6 +96,8 @@ export class StatusComponent {
return 'Removing'
case 'restoring':
return 'Restoring'
case 'error':
return 'Error'
default:
return 'Unknown'
}
@@ -120,6 +123,8 @@ export class StatusComponent {
return 'var(--tui-status-positive)'
case 'actionRequired':
return 'var(--tui-status-warning)'
case 'error':
return 'var(--tui-status-negative)'
case 'installing':
case 'updating':
case 'stopping':

View File

@@ -1,3 +1,4 @@
import { DOCUMENT } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -23,7 +24,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
[disabled]="!isRunning"
[tuiDropdown]="content"
>
{{ 'Launch UI' | i18n }}
{{ 'Open' | i18n }}
</button>
<ng-template #content>
<tui-data-list>
@@ -39,16 +40,15 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
}
</tui-data-list>
</ng-template>
} @else {
<a
} @else if (interfaces[0]) {
<button
tuiIconButton
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[attr.href]="getHref(first)"
[disabled]="!isRunning"
(click)="openUI(interfaces[0])"
>
{{ first?.name }}
</a>
{{ interfaces[0].name }}
</button>
}
`,
styles: `
@@ -61,6 +61,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
})
export class UILaunchComponent {
private readonly config = inject(ConfigService)
private readonly document = inject(DOCUMENT)
@Input()
pkg!: PackageDataEntry
@@ -73,10 +74,6 @@ export class UILaunchComponent {
return this.pkg.status.main === 'running'
}
get first(): T.ServiceInterface | undefined {
return this.interfaces[0]
}
@tuiPure
getInterfaces(pkg?: PackageDataEntry): T.ServiceInterface[] {
return pkg
@@ -89,9 +86,11 @@ export class UILaunchComponent {
: []
}
getHref(ui?: T.ServiceInterface): string | null {
return ui && this.isRunning
? this.config.launchableAddress(ui, this.pkg.hosts)
: null
getHref(ui: T.ServiceInterface): string {
return this.config.launchableAddress(ui, this.pkg.hosts)
}
openUI(ui: T.ServiceInterface) {
this.document.defaultView?.open(this.getHref(ui), '_blank', 'noreferrer')
}
}

View File

@@ -21,7 +21,9 @@ import { i18nPipe } from '@start9labs/shared'
standalone: true,
selector: 'app-action-success-single',
template: `
<p class="qr"><ng-container *ngTemplateOutlet="qr" /></p>
@if (single.qr) {
<p class="qr"><ng-container *ngTemplateOutlet="qr" /></p>
}
<tui-input
[readOnly]="true"
[ngModel]="single.value"

View File

@@ -8,9 +8,11 @@ import {
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { getPkgId, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiItem } from '@taiga-ui/cdk'
import { TuiButton, TuiLink } from '@taiga-ui/core'
import { TuiBreadcrumbs } from '@taiga-ui/kit'
import { TuiBadge, TuiBreadcrumbs } from '@taiga-ui/kit'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
@@ -26,21 +28,34 @@ import { TitleDirective } from 'src/app/services/title.service'
{{ 'Back' | i18n }}
</a>
{{ interface()?.name }}
<interface-status [public]="!!interface()?.public" />
<interface-status
[style.margin-left.rem]="0.5"
[public]="!!interface()?.public"
/>
</ng-container>
<tui-breadcrumbs size="l" [style.margin-block-end.rem]="1">
<tui-breadcrumbs size="l">
<a *tuiItem tuiLink appearance="action-grayscale" routerLink="../..">
{{ 'Dashboard' | i18n }}
</a>
<span *tuiItem class="g-primary">
{{ interface()?.name }}
<interface-status [public]="!!interface()?.public" />
</span>
<span *tuiItem class="g-primary">{{ interface()?.name }}</span>
</tui-breadcrumbs>
@if (interface(); as serviceInterface) {
@if (interface(); as value) {
<header tuiHeader [style.margin-bottom.rem]="1">
<hgroup>
<h3>
{{ value.name }}
<tui-badge size="l" [appearance]="getAppearance(value.type)">
{{ value.type }}
</tui-badge>
<interface-status [public]="value.public" />
</h3>
<p tuiSubtitle>{{ value.description }}</p>
</hgroup>
</header>
<app-interface
[packageId]="pkgId"
[serviceInterface]="serviceInterface"
[value]="value"
[isRunning]="isRunning()"
/>
}
`,
@@ -48,6 +63,19 @@ import { TitleDirective } from 'src/app/services/title.service'
:host-context(tui-root._mobile) tui-breadcrumbs {
display: none;
}
h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0 0.5rem 0;
font-size: 2.4rem;
tui-badge {
text-transform: uppercase;
font-weight: bold;
}
}
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -62,6 +90,8 @@ import { TitleDirective } from 'src/app/services/title.service'
TuiLink,
InterfaceStatusComponent,
i18nPipe,
TuiBadge,
TuiHeader,
],
})
export default class ServiceInterfaceRoute {
@@ -74,6 +104,10 @@ export default class ServiceInterfaceRoute {
inject<PatchDB<DataModel>>(PatchDB).watch$('packageData', this.pkgId),
)
readonly isRunning = computed(() => {
return this.pkg()?.status.main === 'running'
})
readonly interface = computed(() => {
const pkg = this.pkg()
const id = this.interfaceId()
@@ -99,4 +133,15 @@ export default class ServiceInterfaceRoute {
addresses: getAddresses(item, host, this.config),
}
})
getAppearance(type: T.ServiceInterfaceType = 'ui'): string {
switch (type) {
case 'ui':
return 'positive'
case 'api':
return 'info'
case 'p2p':
return 'negative'
}
}
}

View File

@@ -13,9 +13,21 @@ import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import {
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { TitleDirective } from 'src/app/services/title.service'
import { getManifest } from 'src/app/utils/get-package-data'
const INACTIVE: PrimaryStatus[] = [
'installing',
'updating',
'removing',
'restoring',
'backingUp',
]
@Component({
template: `
@if (service()) {
@@ -34,7 +46,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
<span tuiSubtitle>{{ manifest()?.version }}</span>
</span>
</header>
<nav>
<nav [attr.inert]="isInactive() ? '' : null">
@for (item of nav; track $index) {
<a
tuiCell
@@ -76,6 +88,10 @@ import { getManifest } from 'src/app/utils/get-package-data'
margin: 0 -0.5rem;
}
nav[inert] a:not(:first-child) {
opacity: var(--tui-disabled-opacity);
}
a a {
display: none;
}
@@ -178,4 +194,9 @@ export class ServiceOutletComponent {
protected readonly manifest = computed(
(pkg = this.service()) => pkg && getManifest(pkg),
)
protected readonly isInactive = computed(
(pkg = this.service()) =>
!pkg || INACTIVE.includes(renderPkgStatus(pkg).primary),
)
}

View File

@@ -7,11 +7,13 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute } from '@angular/router'
import { isEmptyObject } from '@start9labs/shared'
import { WaIntersectionObserver } from '@ng-web-apis/intersection-observer'
import { i18nPipe, isEmptyObject } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiElement } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { map, of } from 'rxjs'
import { UptimeComponent } from 'src/app/routes/portal/components/uptime.component'
import { ConnectionService } from 'src/app/services/connection.service'
import { DepErrorService } from 'src/app/services/dep-error.service'
import {
@@ -27,14 +29,14 @@ import { ServiceHealthChecksComponent } from '../components/health-checks.compon
import { ServiceInterfacesComponent } from '../components/interfaces.component'
import { ServiceInstallProgressComponent } from '../components/progress.component'
import { ServiceStatusComponent } from '../components/status.component'
import { ServiceUptimeComponent } from '../components/uptime.component'
@Component({
template: `
@if (pkg(); as pkg) {
@if (pkg.status.main === 'error') {
<service-error [pkg]="pkg" />
}
@if (installing()) {
} @else if (installing()) {
<service-install-progress [pkg]="pkg" />
} @else if (installed()) {
<service-status
@@ -42,16 +44,13 @@ import { ServiceStatusComponent } from '../components/status.component'
[installingInfo]="pkg.stateInfo.installingInfo"
[status]="status()"
>
@if ($any(pkg.status)?.started; as started) {
<p class="g-secondary" [appUptime]="started"></p>
}
@if (connected()) {
<service-controls [pkg]="pkg" [status]="status()" />
}
</service-status>
@if (status() !== 'backingUp') {
<service-uptime [started]="$any(pkg.status)?.started" />
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
@if (errors() | async; as errors) {
@@ -63,7 +62,30 @@ import { ServiceStatusComponent } from '../components/status.component'
}
<service-health-checks [checks]="health()" />
<service-tasks [pkg]="pkg" [services]="services() || {}" />
<service-tasks
#tasks="elementRef"
tuiElement
waIntersectionObserver
waIntersectionThreshold="0.5"
(waIntersectionObservee)="scrolled = $event.at(-1)?.isIntersecting"
[pkg]="pkg"
[services]="services() || {}"
/>
<button
tuiIconButton
iconStart="@tui.arrow-down"
tabindex="-1"
class="arrow"
[class.arrow_hidden]="scrolled"
(click)="
tasks.nativeElement.scrollIntoView({
block: 'end',
behavior: 'smooth',
})
"
>
{{ 'Tasks' | i18n }}
</button>
}
} @else if (removing()) {
<service-status
@@ -74,6 +96,14 @@ import { ServiceStatusComponent } from '../components/status.component'
}
`,
styles: `
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
@keyframes bounce {
to {
transform: translateY(-1rem);
}
}
:host {
display: grid;
grid-template-columns: repeat(6, 1fr);
@@ -86,6 +116,23 @@ import { ServiceStatusComponent } from '../components/status.component'
text-transform: uppercase;
}
.arrow {
@include taiga.transition(opacity);
position: sticky;
bottom: 1rem;
border-radius: 100%;
place-self: center;
grid-area: auto / span 6;
box-shadow: inset 0 0 0 2rem var(--tui-status-warning);
animation: bounce 1s infinite alternate;
&_hidden,
:host:has(::ng-deep service-tasks app-placeholder) & {
opacity: 0;
pointer-events: none;
}
}
:host-context(tui-root._mobile) {
grid-template-columns: 1fr;
@@ -99,6 +146,10 @@ import { ServiceStatusComponent } from '../components/status.component'
standalone: true,
imports: [
CommonModule,
TuiElement,
TuiButton,
WaIntersectionObserver,
i18nPipe,
ServiceInstallProgressComponent,
ServiceStatusComponent,
ServiceControlsComponent,
@@ -107,13 +158,15 @@ import { ServiceStatusComponent } from '../components/status.component'
ServiceDependenciesComponent,
ServiceErrorComponent,
ServiceTasksComponent,
UptimeComponent,
ServiceUptimeComponent,
],
})
export class ServiceRoute {
private readonly errorService = inject(DepErrorService)
protected readonly connected = toSignal(inject(ConnectionService))
protected scrolled?: boolean
protected readonly id = toSignal(
inject(ActivatedRoute).paramMap.pipe(map(params => params.get('pkgId'))),
)

View File

@@ -13,6 +13,7 @@ import { TuiCell, TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -89,6 +90,10 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
{{ 'Edit' | i18n }}
</button>
</div>
} @empty {
<app-placeholder icon="@tui.shield-question">
{{ 'No saved providers' | i18n }}
</app-placeholder>
}
} @else {
<tui-loader [style.height.rem]="5" />
@@ -113,6 +118,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
TitleDirective,
i18nPipe,
DocsLinkDirective,
PlaceholderComponent,
],
})
export default class SystemAcmeComponent {

View File

@@ -75,7 +75,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
<button
tuiButton
size="l"
[disabled]="form.invalid"
[disabled]="form.invalid || form.pristine"
(click)="save(form.value)"
>
{{ 'Save' | i18n }}
@@ -98,7 +98,6 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
<footer>
<button
tuiButton
appearance="secondary"
size="l"
[disabled]="!testAddress || form.invalid"
(click)="sendTestEmail(form.value)"
@@ -188,11 +187,14 @@ export default class SystemEmailComponent {
async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) {
const loader = this.loader.open('Sending email').subscribe()
const success =
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}.<br /><br /><b>${this.i18n.transform('Check your spam folder and mark as not spam.')}</b>` as i18nKey
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
try {
await this.api.testSmtp({ to: this.testAddress, ...value })
this.dialog.openAlert(success, { label: 'Success' }).subscribe()
this.dialog
.openAlert(success, { label: 'Success', size: 's' })
.subscribe()
this.testAddress = ''
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -7,6 +7,7 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import {
DialogService,
@@ -30,6 +31,7 @@ import {
TuiTitle,
} from '@taiga-ui/core'
import {
TuiBadge,
TuiButtonLoading,
TuiButtonSelect,
TuiDataListWrapper,
@@ -92,7 +94,9 @@ import { SystemWipeComponent } from './wipe.component'
<tui-icon icon="@tui.app-window" />
<span tuiTitle>
<strong>{{ 'Browser Tab Title' | i18n }}</strong>
<span tuiSubtitle>{{ name() }}</span>
<span tuiSubtitle>
{{ 'Customize the name appearing in your browser tab' | i18n }}
</span>
</span>
<button tuiButton (click)="onTitle()">{{ 'Change' | i18n }}</button>
</div>
@@ -134,6 +138,43 @@ import { SystemWipeComponent } from './wipe.component'
{{ 'Download' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.monitor" />
<span tuiTitle>
<strong>
{{ 'Kiosk Mode' | i18n }}
<tui-badge
size="m"
[appearance]="
server.kiosk ? 'primary-success' : 'primary-destructive'
"
>
{{ server.kiosk ? ('Enabled' | i18n) : ('Disabled' | i18n) }}
</tui-badge>
</strong>
<span tuiSubtitle>
{{
server.kiosk === true
? ('Disable Kiosk Mode unless you need to attach a monitor'
| i18n)
: server.kiosk === false
? ('Enable Kiosk Mode if you need to attach a monitor' | i18n)
: ('Kiosk Mode is unavailable on this device' | i18n)
}}
</span>
</span>
@if (server.kiosk !== null) {
<button
tuiButton
[appearance]="
server.kiosk ? 'primary-destructive' : 'primary-success'
"
(click)="tryToggleKiosk()"
>
{{ server.kiosk ? ('Disable' | i18n) : ('Enable' | i18n) }}
</button>
}
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.circle-power" (click)="count = count + 1" />
<span tuiTitle>
@@ -190,11 +231,6 @@ import { SystemWipeComponent } from './wipe.component'
[tuiCell] {
background: var(--tui-background-neutral-1);
}
[tuiSubtitle],
tui-data-list-wrapper ::ng-deep [tuiOption] {
text-transform: capitalize;
}
`,
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -217,9 +253,11 @@ import { SystemWipeComponent } from './wipe.component'
TuiTextfield,
FormsModule,
SnekDirective,
TuiBadge,
],
})
export default class SystemGeneralComponent {
private readonly title = inject(Title)
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
@@ -276,9 +314,11 @@ export default class SystemGeneralComponent {
})
.subscribe(async name => {
const loader = this.loader.open('Saving').subscribe()
const title = `${name || 'StartOS'}${this.i18n.transform('System')}`
try {
await this.api.setDbValue(['name'], name || null)
this.title.setTitle(title)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -310,6 +350,28 @@ export default class SystemGeneralComponent {
this.document.getElementById('download-ca')?.click()
}
async tryToggleKiosk() {
if (
this.server()?.kiosk &&
['localhost', '127.0.0.1'].includes(this.document.location.hostname)
) {
return this.dialog
.openConfirm({
label: 'Warning',
data: {
content:
'You are currently using a kiosk. Disabling Kiosk Mode will result in the kiosk disconnecting.',
yes: 'Disable',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(async () => this.toggleKiosk())
}
this.toggleKiosk()
}
async onRepair() {
this.dialog
.openConfirm({
@@ -332,6 +394,22 @@ export default class SystemGeneralComponent {
})
}
private async toggleKiosk() {
const kiosk = this.server()?.kiosk
const loader = this.loader
.open(kiosk ? 'Disabling' : 'Enabling')
.subscribe()
try {
await this.api.toggleKiosk(!kiosk)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async resetTor(wipeState: boolean) {
const loader = this.loader.open('Resetting Tor').subscribe()

View File

@@ -24,7 +24,7 @@ import { firstValueFrom } from 'rxjs'
template: `
<h2 style="margin-top: 0">StartOS {{ versions[0]?.version }}</h2>
<h3 style="color: var(--tui-text-secondary); font-weight: normal">
{{ 'Release Notes' | i18n }}
{{ 'Release notes' | i18n }}
</h3>
<tui-scrollbar style="margin-bottom: 24px; max-height: 50vh;">
@for (v of versions; track $index) {

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