diff --git a/container-runtime/package-lock.json b/container-runtime/package-lock.json index 1c8018fc9..2abce19f8 100644 --- a/container-runtime/package-lock.json +++ b/container-runtime/package-lock.json @@ -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", diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts index 6da874757..b0bc32504 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts @@ -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") { diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts index b3b1af569..e314fba74 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts @@ -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 diff --git a/core/startos/src/action.rs b/core/startos/src/action.rs index 5528f7ab1..18765a776 100644 --- a/core/startos/src/action.rs +++ b/core/startos/src/action.rs @@ -348,6 +348,7 @@ pub struct ClearTaskParams { pub package_id: PackageId, pub replay_id: ReplayId, #[arg(long)] + #[serde(default)] pub force: bool, } diff --git a/core/startos/src/diagnostic.rs b/core/startos/src/diagnostic.rs index 92733c972..2c6040e8d 100644 --- a/core/startos/src/diagnostic.rs +++ b/core/startos/src/diagnostic.rs @@ -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() -> ParentHandler { .no_display() .with_about("Remove disk from filesystem"), ) + .subcommand("repair", from_fn_async(|_: C| repair()).no_cli()) + .subcommand( + "repair", + CallRemoteHandler::::new( + from_fn_async(|_: RpcContext| repair()) + .no_display() + .with_about("Repair disk in the event of corruption"), + ), + ) } pub async fn forget_disk(_: C) -> Result<(), Error> { diff --git a/core/startos/src/disk/mount/filesystem/bind.rs b/core/startos/src/disk/mount/filesystem/bind.rs index 196e78a3d..f005e47cf 100644 --- a/core/startos/src/disk/mount/filesystem/bind.rs +++ b/core/startos/src/disk/mount/filesystem/bind.rs @@ -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> { - src_dir: SrcDir, +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "kebab-case")] +pub enum FileType { + File, + Directory, + Infer, } -impl> Bind { - pub fn new(src_dir: SrcDir) -> Self { - Self { src_dir } + +pub struct Bind> { + src: Src, + filetype: FileType, +} +impl> Bind { + 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 + Send + Sync> FileSystem for Bind { +impl + Send + Sync> FileSystem for Bind { async fn source(&self) -> Result>, Error> { - Ok(Some(&self.src_dir)) + Ok(Some(&self.src)) } fn extra_args(&self) -> impl IntoIterator> { ["--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 + Send + Sync> FileSystem for Bind { 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() diff --git a/core/startos/src/disk/mount/filesystem/ecryptfs.rs b/core/startos/src/disk/mount/filesystem/ecryptfs.rs index bf2dfe6c6..bc1a0b65b 100644 --- a/core/startos/src/disk/mount/filesystem/ecryptfs.rs +++ b/core/startos/src/disk/mount/filesystem/ecryptfs.rs @@ -49,8 +49,7 @@ impl + Send + Sync, Key: AsRef + 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) diff --git a/core/startos/src/disk/mount/filesystem/idmapped.rs b/core/startos/src/disk/mount/filesystem/idmapped.rs index dc6a6e9ab..a39b08e56 100644 --- a/core/startos/src/disk/mount/filesystem/idmapped.rs +++ b/core/startos/src/disk/mount/filesystem/idmapped.rs @@ -53,16 +53,15 @@ impl FileSystem for IdMapped { async fn source(&self) -> Result>, 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 + 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) diff --git a/core/startos/src/disk/mount/filesystem/mod.rs b/core/startos/src/disk/mount/filesystem/mod.rs index 80bfcc903..f4c85296a 100644 --- a/core/startos/src/disk/mount/filesystem/mod.rs +++ b/core/startos/src/disk/mount/filesystem/mod.rs @@ -69,8 +69,7 @@ pub(self) async fn default_mount_impl( mountpoint: impl AsRef + 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>, Error>> + Send { async { Ok(None::<&Path>) } } - fn pre_mount(&self) -> impl Future> + Send { - async { Ok(()) } + fn pre_mount(&self, mountpoint: &Path) -> impl Future> + Send { + async move { + tokio::fs::create_dir_all(mountpoint).await?; + Ok(()) + } } fn mount + Send>( &self, diff --git a/core/startos/src/disk/mount/filesystem/overlayfs.rs b/core/startos/src/disk/mount/filesystem/overlayfs.rs index e8d1f0b34..85df0e12c 100644 --- a/core/startos/src/disk/mount/filesystem/overlayfs.rs +++ b/core/startos/src/disk/mount/filesystem/overlayfs.rs @@ -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( diff --git a/core/startos/src/service/effects/dependency.rs b/core/startos/src/service/effects/dependency.rs index 17b8e3067..5bd94e108 100644 --- a/core/startos/src/service/effects/dependency.rs +++ b/core/startos/src/service/effects/dependency.rs @@ -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, readonly: bool, - #[ts(optional)] - filetype: Option, + 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 { diff --git a/core/startos/src/version/v0_3_6_alpha_0.rs b/core/startos/src/version/v0_3_6_alpha_0.rs index b772d6c0b..3954e80db 100644 --- a/core/startos/src/version/v0_3_6_alpha_0.rs +++ b/core/startos/src/version/v0_3_6_alpha_0.rs @@ -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"] = diff --git a/sdk/base/lib/Effects.ts b/sdk/base/lib/Effects.ts index 774fc519b..91231f19c 100644 --- a/sdk/base/lib/Effects.ts +++ b/sdk/base/lib/Effects.ts @@ -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 /** mount a volume of a dependency */ - mount(options: { - location: string - target: { - packageId: string - volumeId: string - subpath: string | null - readonly: boolean - } - }): Promise + mount(options: MountParams): Promise /** Returns a list of the ids of all installed packages */ getInstalledPackages(): Promise diff --git a/sdk/base/lib/osBindings/FileType.ts b/sdk/base/lib/osBindings/FileType.ts index 82b9ca474..85eb97ad6 100644 --- a/sdk/base/lib/osBindings/FileType.ts +++ b/sdk/base/lib/osBindings/FileType.ts @@ -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" diff --git a/sdk/base/lib/osBindings/MountTarget.ts b/sdk/base/lib/osBindings/MountTarget.ts index e208383e3..456f17052 100644 --- a/sdk/base/lib/osBindings/MountTarget.ts +++ b/sdk/base/lib/osBindings/MountTarget.ts @@ -8,5 +8,5 @@ export type MountTarget = { volumeId: VolumeId subpath: string | null readonly: boolean - filetype?: FileType + filetype: FileType } diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 350f26d0b..9f303fa87 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -412,7 +412,7 @@ export class StartSdk { 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 diff --git a/sdk/package/lib/util/fileHelper.ts b/sdk/package/lib/util/fileHelper.ts index 01de88c0b..2ff2c5d5f 100644 --- a/sdk/package/lib/util/fileHelper.ts +++ b/sdk/package/lib/util/fileHelper.ts @@ -187,10 +187,10 @@ export class FileHelper { /** * Reads the file from disk and converts it to structured data. */ - private async readOnce(): Promise { + private async readOnce(map: (value: A) => B): Promise { const data = await this.readFile() if (!data) return null - return this.validate(data) + return map(this.validate(data)) } private async readConst( @@ -224,8 +224,7 @@ export class FileHelper { 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 { 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: ( diff --git a/sdk/package/package-lock.json b/sdk/package/package-lock.json index a25775d69..cada2299d 100644 --- a/sdk/package/package-lock.json +++ b/sdk/package/package-lock.json @@ -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", diff --git a/sdk/package/package.json b/sdk/package/package.json index 5188305f7..36bfc09cb 100644 --- a/sdk/package/package.json +++ b/sdk/package/package.json @@ -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", diff --git a/web/patchdb-ui-seed.json b/web/patchdb-ui-seed.json index 5ecca58d5..2473812b4 100644 --- a/web/patchdb-ui-seed.json +++ b/web/patchdb-ui-seed.json @@ -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": {} } diff --git a/web/projects/install-wizard/src/app/app.component.scss b/web/projects/install-wizard/src/app/app.component.scss index b907f84ba..d3f166d6b 100644 --- a/web/projects/install-wizard/src/app/app.component.scss +++ b/web/projects/install-wizard/src/app/app.component.scss @@ -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); +} + diff --git a/web/projects/install-wizard/src/app/app.component.ts b/web/projects/install-wizard/src/app/app.component.ts index 0a5f4e98d..75cc8fde3 100644 --- a/web/projects/install-wizard/src/app/app.component.ts +++ b/web/projects/install-wizard/src/app/app.component.ts @@ -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) { diff --git a/web/projects/marketplace/src/pages/show/about/about.component.html b/web/projects/marketplace/src/pages/show/about/about.component.html index fd54747c1..fb90300d4 100644 --- a/web/projects/marketplace/src/pages/show/about/about.component.html +++ b/web/projects/marketplace/src/pages/show/about/about.component.html @@ -12,7 +12,7 @@ Past Release Notes

About

-

{{ pkg.description.long }}

+

- (required) - (optional) + (optional) + (required)

diff --git a/web/projects/setup-wizard/src/app/app.component.ts b/web/projects/setup-wizard/src/app/app.component.ts index cecb85eec..cd2ca9e6a 100644 --- a/web/projects/setup-wizard/src/app/app.component.ts +++ b/web/projects/setup-wizard/src/app/app.component.ts @@ -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' diff --git a/web/projects/setup-wizard/src/app/app.module.ts b/web/projects/setup-wizard/src/app/app.module.ts index cbfe318e6..0ed693006 100644 --- a/web/projects/setup-wizard/src/app/app.module.ts +++ b/web/projects/setup-wizard/src/app/app.module.ts @@ -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], }) diff --git a/web/projects/setup-wizard/src/app/components/matrix.component.ts b/web/projects/setup-wizard/src/app/components/matrix.component.ts index 7a58e11f0..02cade784 100644 --- a/web/projects/setup-wizard/src/app/components/matrix.component.ts +++ b/web/projects/setup-wizard/src/app/components/matrix.component.ts @@ -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) diff --git a/web/projects/setup-wizard/src/app/components/password.component.ts b/web/projects/setup-wizard/src/app/components/password.component.ts index 49be43337..2b6e3b2a0 100644 --- a/web/projects/setup-wizard/src/app/components/password.component.ts +++ b/web/projects/setup-wizard/src/app/components/password.component.ts @@ -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. } -
- - Enter Password - - - + + + + + + + @if (storageDrive) { - - Retype Password - - - + + + + + + }
-
`, - 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>() 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') } diff --git a/web/projects/setup-wizard/src/app/pages/attach.page.ts b/web/projects/setup-wizard/src/app/pages/attach.page.ts index 47cdd3769..a77c1b147 100644 --- a/web/projects/setup-wizard/src/app/pages/attach.page.ts +++ b/web/projects/setup-wizard/src/app/pages/attach.page.ts @@ -17,7 +17,7 @@ import { StateService } from 'src/app/services/state.service' @Component({ standalone: true, template: ` -
+
Use existing drive
Select the physical drive containing your StartOS data
@@ -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. } - +
+ +
}
`, diff --git a/web/projects/setup-wizard/src/app/pages/home.page.ts b/web/projects/setup-wizard/src/app/pages/home.page.ts index a04a1cb86..b995a64f1 100644 --- a/web/projects/setup-wizard/src/app/pages/home.page.ts +++ b/web/projects/setup-wizard/src/app/pages/home.page.ts @@ -13,7 +13,7 @@ import { StateService } from 'src/app/services/state.service' template: ` @if (!loading) { -
+
@if (recover) { @@ -49,10 +49,11 @@ import { StateService } from 'src/app/services/state.service' (password)="select($event, server)" > } - - +
+ +
}
`, diff --git a/web/projects/setup-wizard/src/app/pages/storage.page.ts b/web/projects/setup-wizard/src/app/pages/storage.page.ts index 2e45aea9a..40a6b4f51 100644 --- a/web/projects/setup-wizard/src/app/pages/storage.page.ts +++ b/web/projects/setup-wizard/src/app/pages/storage.page.ts @@ -19,7 +19,7 @@ import { StateService } from 'src/app/services/state.service' @Component({ standalone: true, template: ` -
+
@if (loading || drives.length) {
Select storage drive
This is the drive where your StartOS data will be stored. @@ -39,10 +39,11 @@ import { StateService } from 'src/app/services/state.service' } } - - +
+ +
`, imports: [TuiCardLarge, TuiLoader, TuiCell, TuiButton, DriveComponent], diff --git a/web/projects/setup-wizard/src/app/pages/success.page.ts b/web/projects/setup-wizard/src/app/pages/success.page.ts index 7d0c25343..7973980c4 100644 --- a/web/projects/setup-wizard/src/app/pages/success.page.ts +++ b/web/projects/setup-wizard/src/app/pages/success.page.ts @@ -18,15 +18,13 @@ import { StateService } from 'src/app/services/state.service' standalone: true, template: ` - @if (isKiosk) { + @if (stateService.kiosk) {

Setup Complete!

- +
} @else if (lanAddress) {
@@ -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 - 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:'), ) diff --git a/web/projects/setup-wizard/src/app/pages/transfer.page.ts b/web/projects/setup-wizard/src/app/pages/transfer.page.ts index 6f1f206f9..21764d6f1 100644 --- a/web/projects/setup-wizard/src/app/pages/transfer.page.ts +++ b/web/projects/setup-wizard/src/app/pages/transfer.page.ts @@ -21,7 +21,7 @@ import { StateService } from 'src/app/services/state.service' @Component({ standalone: true, template: ` -
+
Transfer
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) { } - +
+ +
`, imports: [TuiCardLarge, TuiCell, TuiButton, TuiLoader, DriveComponent], diff --git a/web/projects/setup-wizard/src/app/services/state.service.ts b/web/projects/setup-wizard/src/app/services/state.service.ts index 8a7084f55..fc8e3e66e 100644 --- a/web/projects/setup-wizard/src/app/services/state.service.ts +++ b/web/projects/setup-wizard/src/app/services/state.service.ts @@ -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 @@ -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, }) } } diff --git a/web/projects/setup-wizard/src/styles.scss b/web/projects/setup-wizard/src/styles.scss index 632283006..818b4ea92 100644 --- a/web/projects/setup-wizard/src/styles.scss +++ b/web/projects/setup-wizard/src/styles.scss @@ -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); +} diff --git a/web/projects/shared/assets/img/background_marketplace.jpg b/web/projects/shared/assets/img/background_marketplace.jpg new file mode 100644 index 000000000..b4ba1aeb1 Binary files /dev/null and b/web/projects/shared/assets/img/background_marketplace.jpg differ diff --git a/web/projects/shared/assets/img/background_marketplace.png b/web/projects/shared/assets/img/background_marketplace.png deleted file mode 100644 index f775b8bf6..000000000 Binary files a/web/projects/shared/assets/img/background_marketplace.png and /dev/null differ diff --git a/web/projects/shared/src/components/initializing.component.ts b/web/projects/shared/src/components/initializing.component.ts index 84cb1755b..0a7dfde42 100644 --- a/web/projects/shared/src/components/initializing.component.ts +++ b/web/projects/shared/src/components/initializing.component.ts @@ -12,7 +12,7 @@ import { i18nPipe } from '../i18n/i18n.pipe'

{{ 'Setting up your server' | i18n }}

-
+
{{ 'Progress' | i18n }}: {{ (progress.total * 100).toFixed(0) }}%
-

+

`, diff --git a/web/projects/shared/src/components/ticker.component.ts b/web/projects/shared/src/components/ticker.component.ts index 0f1652a90..c3340bc70 100644 --- a/web/projects/shared/src/components/ticker.component.ts +++ b/web/projects/shared/src/components/ticker.component.ts @@ -21,7 +21,6 @@ import { &:hover { text-indent: var(--indent, 0); text-overflow: clip; - cursor: default; } } `, diff --git a/web/projects/shared/src/directives/docs-link.directive.ts b/web/projects/shared/src/directives/docs-link.directive.ts index 3aebf54d2..5c0aa74f0 100644 --- a/web/projects/shared/src/directives/docs-link.directive.ts +++ b/web/projects/shared/src/directives/docs-link.directive.ts @@ -6,7 +6,6 @@ import { input, } from '@angular/core' -const HOST = 'https://staging.docs.start9.com' export const VERSION = new InjectionToken('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}` }) } diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index a68a62368..a59c6efb7 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -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 diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 552293dd1..e012c9fe6 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -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 diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index 59643fea8..918528436 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -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 diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index 35e5bfec4..e0e645df9 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -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 l’interface utilisateur', + 85: 'Désinstallation forcée', 86: 'Afficher le QR', 87: 'Copier l’URL', 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 l’onglet 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 n’est 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 diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index bd1ad4807..185d696da 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -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 diff --git a/web/projects/shared/src/i18n/i18n.pipe.ts b/web/projects/shared/src/i18n/i18n.pipe.ts index 6ff5f8abc..d7cb56245 100644 --- a/web/projects/shared/src/i18n/i18n.pipe.ts +++ b/web/projects/shared/src/i18n/i18n.pipe.ts @@ -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 diff --git a/web/projects/shared/src/i18n/i18n.service.ts b/web/projects/shared/src/i18n/i18n.service.ts index 6911d71e6..b627cddbf 100644 --- a/web/projects/shared/src/i18n/i18n.service.ts +++ b/web/projects/shared/src/i18n/i18n.service.ts @@ -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] diff --git a/web/projects/shared/src/util/format-progress.ts b/web/projects/shared/src/util/format-progress.ts index 9a6a1401e..5857e815f 100644 --- a/web/projects/shared/src/util/format-progress.ts +++ b/web/projects/shared/src/util/format-progress.ts @@ -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 => `${p.name}${getPhaseBytes(p.progress)}`) + .map(p => `${p.name}: (${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}` } diff --git a/web/projects/shared/styles/shared.scss b/web/projects/shared/styles/shared.scss index fb30fb3f9..884bef184 100644 --- a/web/projects/shared/styles/shared.scss +++ b/web/projects/shared/styles/shared.scss @@ -277,6 +277,7 @@ body { vertical-align: bottom; animation: ellipsis-dot 1s infinite 0.3s; animation-fill-mode: forwards; + text-align: left; width: 1em; } diff --git a/web/projects/shared/styles/taiga.scss b/web/projects/shared/styles/taiga.scss index fc3716a9f..4d4d7e4ac 100644 --- a/web/projects/shared/styles/taiga.scss +++ b/web/projects/shared/styles/taiga.scss @@ -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; diff --git a/web/projects/ui/src/app/routes/initializing/initializing.page.ts b/web/projects/ui/src/app/routes/initializing/initializing.page.ts index be48a1dbf..057039a2f 100644 --- a/web/projects/ui/src/app/routes/initializing/initializing.page.ts +++ b/web/projects/ui/src/app/routes/initializing/initializing.page.ts @@ -48,6 +48,6 @@ export default class InitializingPage { return caught$ }), ), - { initialValue: { total: 0, message: '' } }, + { initialValue: { total: 0, message: 'waiting...' } }, ) } diff --git a/web/projects/ui/src/app/routes/login/login.page.ts b/web/projects/ui/src/app/routes/login/login.page.ts index bfabf4a9e..5a0badb78 100644 --- a/web/projects/ui/src/app/routes/login/login.page.ts +++ b/web/projects/ui/src/app/routes/login/login.page.ts @@ -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() } } diff --git a/web/projects/ui/src/app/routes/portal/components/form/form-array/form-array.component.ts b/web/projects/ui/src/app/routes/portal/components/form/form-array/form-array.component.ts index 0b87734e1..b30cc499b 100644 --- a/web/projects/ui/src/app/routes/portal/components/form/form-array/form-array.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/form/form-array/form-array.component.ts @@ -73,20 +73,7 @@ export class FormArrayComponent { } removeAt(index: number) { - this.dialog - .openConfirm({ - 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) { diff --git a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts index fe44771a2..27f250814 100644 --- a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts @@ -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" diff --git a/web/projects/ui/src/app/routes/portal/components/header/navigation.component.ts b/web/projects/ui/src/app/routes/portal/components/header/navigation.component.ts index cfdbe370a..5855b90ec 100644 --- a/web/projects/ui/src/app/routes/portal/components/header/navigation.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/header/navigation.component.ts @@ -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)" diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/actions.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/actions.component.ts index 22493c80e..53b0eb475 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/actions.component.ts @@ -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: `
- @if (interface.serviceInterface().type === 'ui') { - - {{ 'Launch UI' | i18n }} - + {{ 'Open' | i18n }} + } - @@ -55,27 +62,26 @@ import { InterfaceComponent } from './interface.component' - @if (interface.serviceInterface().type === 'ui') { - - {{ 'Launch UI' | i18n }} - - + @if (interface.value().type === 'ui') { } + + @@ -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() + readonly href = input.required() + readonly disabled = input.required() 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') + } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts index 3b1decabe..2f70425d6 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/clearnet.component.ts @@ -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 = { }} {{ 'Learn more' | i18n }} - @if (clearnet().length) { +
+
+
`, 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() + readonly value = input.required() + readonly isRunning = input.required() + + 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() + } + } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts index 068634762..a4d278fdf 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/local.component.ts @@ -29,7 +29,7 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' {{ address.nid }} {{ address.url | mask }} - + } @@ -49,4 +49,5 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared' }) export class InterfaceLocalComponent { readonly local = input.required() + readonly isRunning = input.required() } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/mask.pipe.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/mask.pipe.ts index 2a47a88b3..951c273f6 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/mask.pipe.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/mask.pipe.ts @@ -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 } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/status.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/status.component.ts index 3933f854f..5075bcfb5 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/status.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/status.component.ts @@ -9,13 +9,16 @@ import { TuiBadge } from '@taiga-ui/kit' {{ public() ? ('Public' | i18n) : ('Private' | i18n) }} `, + styles: ` + :host { + display: inline-flex; + } + `, changeDetection: ChangeDetectionStrategy.OnPush, imports: [TuiBadge, i18nPipe], }) diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts index 7ecfb0f44..91979b1ba 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/tor.component.ts @@ -76,11 +76,11 @@ type OnionForm = { {{ address.url | mask }}
- + - {{ logs[key]?.title | i18n }} - } @else { - {{ 'Logs' | i18n }} - } - - @if (current(); as key) { -
- - - {{ logs[key]?.title | i18n }} - -

{{ logs[key]?.subtitle | i18n }}

-
- @for (log of logs | keyvalue; track $index) { - @if (log.key === current()) { - - } - } - } @else { - @for (log of logs | keyvalue; track $index) { - - } - } - `, - 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(null) - readonly logs: Record = { - 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), - }, - } -} diff --git a/web/projects/ui/src/app/routes/portal/routes/logs/logs.routes.ts b/web/projects/ui/src/app/routes/portal/routes/logs/logs.routes.ts new file mode 100644 index 000000000..281683fcd --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/logs/logs.routes.ts @@ -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 diff --git a/web/projects/ui/src/app/routes/portal/routes/logs/routes/kernel.component.ts b/web/projects/ui/src/app/routes/portal/routes/logs/routes/kernel.component.ts new file mode 100644 index 000000000..4a3c85990 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/logs/routes/kernel.component.ts @@ -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: ` + + {{ 'Diagnostics for drivers and other kernel processes' | i18n }} + + + `, + 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', + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/logs/routes/os.component.ts b/web/projects/ui/src/app/routes/portal/routes/logs/routes/os.component.ts new file mode 100644 index 000000000..ab1e861f1 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/logs/routes/os.component.ts @@ -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: ` + + {{ 'Raw, unfiltered operating system logs' | i18n }} + + + `, + 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', + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/logs/routes/outlet.component.ts b/web/projects/ui/src/app/routes/portal/routes/logs/routes/outlet.component.ts new file mode 100644 index 000000000..9c7c34669 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/logs/routes/outlet.component.ts @@ -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: ` + {{ 'Logs' | i18n }} + @for (log of logs; track $index) { + + + + {{ log.title | i18n }} + {{ log.subtitle | i18n }} + + + + } + `, + 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 +} diff --git a/web/projects/ui/src/app/routes/portal/routes/logs/routes/tor.component.ts b/web/projects/ui/src/app/routes/portal/routes/logs/routes/tor.component.ts new file mode 100644 index 000000000..b84021e0d --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/logs/routes/tor.component.ts @@ -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: ` + + {{ 'Diagnostic logs for the Tor daemon on StartOS' | i18n }} + + + `, + 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', + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts index 18a7b11c5..8bc32c58e 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts @@ -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 { diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts index a7fb73226..533245480 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts @@ -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' {{ 'Ok' | i18n }} @@ -91,7 +90,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service' } @else { - + } `, @@ -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)) } } diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/temperature.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/temperature.component.ts index ae324abb7..77be2f87c 100644 --- a/web/projects/ui/src/app/routes/portal/routes/metrics/temperature.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/temperature.component.ts @@ -49,7 +49,7 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core' - {{ value() || '-' }} C° + {{ value() ? value() + ' C°' : 'N/A' }} `, styles: ` @import '@taiga-ui/core/styles/taiga-ui-local'; diff --git a/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts index 2b4633cf3..ff64a1347 100644 --- a/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts @@ -55,7 +55,11 @@ import { i18nPipe } from '@start9labs/shared' } @if (notificationItem.code === 1 || notificationItem.code === 2) { } @@ -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)) { diff --git a/web/projects/ui/src/app/routes/portal/routes/notifications/notifications.component.ts b/web/projects/ui/src/app/routes/portal/routes/notifications/notifications.component.ts index d028a334d..03c2ad6c3 100644 --- a/web/projects/ui/src/app/routes/portal/routes/notifications/notifications.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/notifications/notifications.component.ts @@ -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(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) diff --git a/web/projects/ui/src/app/routes/portal/routes/notifications/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/notifications/table.component.ts index 40dd4d14c..60a9825a1 100644 --- a/web/projects/ui/src/app/routes/portal/routes/notifications/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/notifications/table.component.ts @@ -31,7 +31,7 @@ import { i18nPipe } from '@start9labs/shared' /> {{ 'Date' | i18n }} - {{ 'Title' | i18n }} + {{ 'Title' | i18n }} {{ 'Service' | i18n }} {{ 'Message' | i18n }} @@ -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, diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/action.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/action.component.ts index 38ba7b416..2ff2d1bf6 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/action.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/action.component.ts @@ -14,7 +14,7 @@ interface ActionItem { template: `
{{ action.name }} -
{{ action.description }}
+
@if (disabled) {
{{ disabled }}
} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/dependencies.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/dependencies.component.ts index 891c1823f..3c78c2551 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/dependencies.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/dependencies.component.ts @@ -27,7 +27,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model' /> - {{ d.value.title }} + {{ d.value.title || d.key }} @if (getError(d.key); as error) { {{ error | i18n }} } @else { diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/error.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/error.component.ts index f2c8c2ad2..b072ab9bd 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/error.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/error.component.ts @@ -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: ` -
{{ 'Error' | i18n }}
- +
{{ 'Service Launch Error' | i18n }}
+

{{ error?.message }}

+

{{ error?.debug }}

{{ 'Actions' | i18n }} @@ -34,7 +31,13 @@ import { getManifest } from 'src/app/utils/get-package-data'

{{ - '"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 + }} +

+

+ {{ + '"Hard uninstall" is a dangerous action that will remove the service from StartOS and wipe all its data.' | i18n }}

@@ -43,8 +46,11 @@ import { getManifest } from 'src/app/utils/get-package-data' - + @if (overflow) { } + `, 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') } } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/interfaces.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/interfaces.component.ts index a4c649852..dc6358e16 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/interfaces.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/interfaces.component.ts @@ -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' {{ 'Name' | i18n }} {{ 'Type' | i18n }} + {{ 'Hosting' | i18n }} {{ 'Description' | i18n }} - {{ 'Hosting' | i18n }} @@ -33,7 +32,6 @@ import { i18nPipe } from '@start9labs/shared' @for (info of interfaces(); track $index) { + - {{ title() }} + {{ pkgTitle() }} - @if (actionRequest().severity === 'critical') { + {{ pkg()?.actions?.[task().actionId]?.name }} + + + @if (task().severity === 'critical') { {{ 'Required' | i18n }} - } @else { + } @else if (task().severity === 'important') { + {{ 'Recommended' | i18n }} + + } @else { + {{ 'Optional' | i18n }} } - {{ actionRequest().reason || ('No reason provided' | i18n) }} + {{ task().reason || ('No reason provided' | i18n) }} - + @if (task().severity !== 'critical') { + + } + `, 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() + readonly task = input.required() readonly services = input.required>() - 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({ + 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(), }) } } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/tasks.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/tasks.component.ts index 6909d1776..b088a69a2 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/tasks.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/tasks.component.ts @@ -19,18 +19,19 @@ import { i18nPipe } from '@start9labs/shared' {{ 'Service' | i18n }} - {{ 'Type' | i18n }} + {{ 'Action' }} + {{ 'Severity' }} {{ 'Description' | i18n }} - @for (item of requests(); track $index) { - + @for (item of tasks(); track $index) { + } - @if (!requests().length) { + @if (!tasks().length) { {{ 'All tasks complete' | i18n }} @@ -50,8 +51,12 @@ export class ServiceTasksComponent { readonly pkg = input.required() readonly services = input.required>() - 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] && diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/uptime.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/uptime.component.ts new file mode 100644 index 000000000..98ba27d9d --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/uptime.component.ts @@ -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: ` +
{{ 'Uptime' | i18n }}
+
+ @if (uptime$ | async; as time) { +
+ + {{ 'Days' | i18n }} +
+
+ + {{ 'Hours' | i18n }} +
+
+ + {{ 'Minutes' | i18n }} +
+
+ + {{ 'Seconds' | i18n }} +
+ } +
+ `, + 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('') +} diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/controls.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/controls.component.ts index fc4afcc79..c7f8866a5 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/controls.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/controls.component.ts @@ -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: ` + @if (running()) { @@ -39,16 +40,15 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model' } - } @else { - - {{ first?.name }} - + {{ interfaces[0].name }} + } `, 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') } } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-single.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-single.component.ts index 1843906e5..749721a34 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-single.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/modals/action-success/action-success-single.component.ts @@ -21,7 +21,9 @@ import { i18nPipe } from '@start9labs/shared' standalone: true, selector: 'app-action-success-single', template: ` -

+ @if (single.qr) { +

+ } {{ interface()?.name }} - + - + {{ 'Dashboard' | i18n }} - - {{ interface()?.name }} - - + {{ interface()?.name }} - @if (interface(); as serviceInterface) { + @if (interface(); as value) { +
+
+

+ {{ value.name }} + + {{ value.type }} + + +

+

{{ value.description }}

+
+
} `, @@ -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).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' + } + } } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts index a623cb3b4..13195554d 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/outlet.component.ts @@ -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' {{ manifest()?.version }} -

+ } @empty { + + {{ 'No saved providers' | i18n }} + } } @else { @@ -113,6 +118,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' TitleDirective, i18nPipe, DocsLinkDirective, + PlaceholderComponent, ], }) export default class SystemAcmeComponent { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts index 2dfe9ff71..27beaf870 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts @@ -75,7 +75,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' @@ -134,6 +138,43 @@ import { SystemWipeComponent } from './wipe.component' {{ 'Download' | i18n }} +
+ + + + {{ 'Kiosk Mode' | i18n }} + + {{ server.kiosk ? ('Enabled' | i18n) : ('Disabled' | i18n) }} + + + + {{ + 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) + }} + + + @if (server.kiosk !== null) { + + } +
@@ -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() diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/update.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/update.component.ts index 3226b6e28..0bca7633f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/update.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/update.component.ts @@ -24,7 +24,7 @@ import { firstValueFrom } from 'rxjs' template: `

StartOS {{ versions[0]?.version }}

- {{ 'Release Notes' | i18n }} + {{ 'Release notes' | i18n }}

@for (v of versions; track $index) { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/sessions.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/sessions.component.ts index 465e23665..05d40be49 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/sessions.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/sessions.component.ts @@ -1,13 +1,18 @@ +import { CommonModule } from '@angular/common' +import { + ChangeDetectionStrategy, + Component, + inject, + viewChild, +} from '@angular/core' import { RouterLink } from '@angular/router' +import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared' import { TuiLet } from '@taiga-ui/cdk' import { TuiButton, TuiTitle } from '@taiga-ui/core' -import { CommonModule } from '@angular/common' -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared' import { TuiHeader } from '@taiga-ui/layout' import { from, map, merge, Observable, Subject } from 'rxjs' -import { ApiService } from 'src/app/services/api/embassy-api.service' import { Session } 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' import { SessionsTableComponent } from './table.component' @@ -36,18 +41,16 @@ import { SessionsTableComponent } from './table.component'
{{ 'Other sessions' | i18n }} - @if (table.selected$ | async; as selected) { - - } +
@@ -73,6 +76,8 @@ export default class SystemSessionsComponent { private readonly sessions$ = from(this.api.getSessions({})) private readonly local$ = new Subject() + protected sessions = viewChild>('table') + readonly current$ = this.sessions$.pipe( map(s => { const current = s.sessions[s.current] @@ -99,16 +104,14 @@ export default class SystemSessionsComponent { ), ) - async terminate( - sessions: readonly SessionWithId[], - all: readonly SessionWithId[], - ) { - const ids = sessions.map(s => s.id) + async terminate(all: readonly SessionWithId[]) { + const ids = this.sessions()?.selected$.value.map(s => s.id) || [] const loader = this.loader.open('Terminating sessions').subscribe() try { await this.api.killSessions({ ids }) this.local$.next(all.filter(s => !ids.includes(s.id))) + this.sessions()?.selected$.next([]) } catch (e: any) { this.errorService.handleError(e) } finally { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts index ae9a83165..069a95e7a 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts @@ -31,7 +31,7 @@ import { i18nPipe } from '@start9labs/shared' (ngModelChange)="onToggle(session)" /> } - {{ session.userAgent }} + {{ session.userAgent || '-' }} @if (session.userAgent | platformInfo; as platform) { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/interfaces/interfaces.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts similarity index 85% rename from web/projects/ui/src/app/routes/portal/routes/system/routes/interfaces/interfaces.component.ts rename to web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts index a185ce1f7..0654ec305 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/interfaces/interfaces.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts @@ -25,11 +25,11 @@ import { TitleDirective } from 'src/app/services/title.service'
{{ 'Back' | i18n }} - StartOS UI - + {{ iface.name }} +
-
+

{{ iface.name }} @@ -38,9 +38,24 @@ import { TitleDirective } from 'src/app/services/title.service'

@if (ui(); as ui) { - + } `, + styles: ` + 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, standalone: true, imports: [ @@ -49,7 +64,6 @@ import { TitleDirective } from 'src/app/services/title.service' TuiButton, TitleDirective, TuiHeader, - TuiTitle, InterfaceStatusComponent, i18nPipe, ], diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts index 4e4335520..719a98e70 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts @@ -149,7 +149,7 @@ export default class SystemWifiComponent { async onToggle(enable: boolean) { const loader = this.loader - .open(enable ? 'Enabling WiFi' : 'Disabling WiFi') + .open(enable ? 'Enabling' : 'Disabling') .subscribe() try { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts index 385f016ed..a62ff504e 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts @@ -11,6 +11,7 @@ import { BadgeService } from 'src/app/services/badge.service' import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' import { SYSTEM_MENU } from './system.const' +import { map } from 'rxjs' @Component({ template: ` @@ -128,10 +129,7 @@ import { SYSTEM_MENU } from './system.const' export class SystemComponent { readonly menu = SYSTEM_MENU readonly badge = toSignal(inject(BadgeService).getCount('/portal/system')) - readonly wifiEnabled$ = inject>(PatchDB).watch$( - 'serverInfo', - 'network', - 'wifi', - 'enabled', - ) + readonly wifiEnabled$ = inject>(PatchDB) + .watch$('serverInfo', 'network', 'wifi') + .pipe(map(wifi => !!wifi.interface && wifi.enabled)) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts index c6d0ff2ec..0bbb161b9 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts @@ -7,11 +7,6 @@ export const SYSTEM_MENU = [ item: 'General', link: 'general', }, - { - icon: '@tui.mail', - item: 'Email', - link: 'email', - }, ], [ { @@ -36,6 +31,11 @@ export const SYSTEM_MENU = [ item: 'ACME', link: 'acme', }, + { + icon: '@tui.mail', + item: 'Email', + link: 'email', + }, { icon: '@tui.wifi', item: 'WiFi', diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts index 18e43c31d..ff05793c1 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts @@ -6,6 +6,7 @@ import { Routes, } from '@angular/router' import { TUI_IS_MOBILE } from '@taiga-ui/cdk' +import { titleResolver } from 'src/app/utils/title-resolver' import { SystemComponent } from './system.component' export default [ @@ -21,40 +22,49 @@ export default [ children: [ { path: 'general', + title: titleResolver, loadComponent: () => import('./routes/general/general.component'), }, { path: 'email', + title: titleResolver, loadComponent: () => import('./routes/email/email.component'), }, { path: 'backup', + title: titleResolver, loadComponent: () => import('./routes/backups/backups.component'), data: { type: 'create' }, }, { path: 'restore', + title: titleResolver, loadComponent: () => import('./routes/backups/backups.component'), data: { type: 'restore' }, }, { path: 'interfaces', - loadComponent: () => import('./routes/interfaces/interfaces.component'), + title: titleResolver, + loadComponent: () => import('./routes/startos-ui/startos-ui.component'), }, { path: 'acme', + title: titleResolver, loadComponent: () => import('./routes/acme/acme.component'), }, { path: 'wifi', + title: titleResolver, loadComponent: () => import('./routes/wifi/wifi.component'), }, { path: 'sessions', + title: titleResolver, loadComponent: () => import('./routes/sessions/sessions.component'), }, { path: 'password', + title: titleResolver, loadComponent: () => import('./routes/password/password.component'), }, // { diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts index 6f994d69e..f18f671bd 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts @@ -212,6 +212,11 @@ import UpdatesComponent from './updates.component' .mobile { display: flex; + gap: 0.25rem; + + [tuiSubtitle] { + color: var(--tui-text-secondary); + } } } `, diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts b/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts index 9640318b0..27b6b5842 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts @@ -21,6 +21,7 @@ import { import { TuiCell } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' import { combineLatest, map, tap } from 'rxjs' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { TableComponent } from 'src/app/routes/portal/components/table.component' import { MarketplaceService } from 'src/app/services/marketplace.service' import { @@ -112,7 +113,9 @@ interface UpdatesData { } @empty { - {{ 'All services are up to date!' | i18n }} + + {{ 'All services are up to date!' | i18n }} + } @@ -161,6 +164,11 @@ interface UpdatesData { clip-path: inset(0.5rem round var(--tui-radius-s)); } + .g-subpage, + .g-card { + overflow: auto; + } + :host-context(tui-root._mobile) { aside { width: 100%; @@ -214,6 +222,7 @@ interface UpdatesData { TitleDirective, TableComponent, i18nPipe, + PlaceholderComponent, ], }) export default class UpdatesComponent { @@ -224,7 +233,7 @@ export default class UpdatesComponent { readonly data = toSignal( combineLatest({ - hosts: this.marketplaceService.filteredRegistries$.pipe( + hosts: this.marketplaceService.registries$.pipe( tap( ([registry]) => !this.isMobile && registry && this.current.set(registry), diff --git a/web/projects/ui/src/app/routing.module.ts b/web/projects/ui/src/app/routing.module.ts index 94d0f05da..dd0432b57 100644 --- a/web/projects/ui/src/app/routing.module.ts +++ b/web/projects/ui/src/app/routing.module.ts @@ -27,7 +27,7 @@ const routes: Routes = [ loadChildren: () => import('./routes/portal/portal.routes'), }, { - path: '', + path: '**', redirectTo: 'portal', pathMatch: 'full', }, diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index 2f188a904..0bfb4fa87 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -18,7 +18,7 @@ const mockMerkleArchiveCommitment: T.MerkleArchiveCommitment = { const mockDescription = { short: 'Lorem ipsum dolor sit amet', - long: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', + long: 'Lorem ipsum dolor sit amet,

consectetur adipiscing elit

, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', } export namespace Mock { @@ -632,55 +632,6 @@ export namespace Mock { }, }, }, - 'btc-rpc-proxy': { - '=0.3.2.6:0': { - best: { - '0.3.2.6:0': { - title: 'Bitcoin Proxy', - description: mockDescription, - hardwareRequirements: { arch: null, device: [], ram: null }, - license: 'mit', - wrapperRepo: 'https://github.com/Start9Labs/btc-rpc-proxy-wrappers', - upstreamRepo: 'https://github.com/Kixunil/btc-rpc-proxy', - supportSite: 'https://github.com/Kixunil/btc-rpc-proxy/issues', - marketingSite: '', - releaseNotes: 'Upstream release and minor fixes.', - osVersion: '0.3.6', - gitHash: 'fakehash', - icon: PROXY_ICON, - sourceVersion: null, - dependencyMetadata: { - bitcoind: { - title: 'Bitcoin Core', - icon: BTC_ICON, - description: 'Used for RPC requests', - optional: false, - }, - }, - donationUrl: null, - alerts: { - install: 'test', - uninstall: 'test', - start: 'test', - stop: 'test', - restore: 'test', - }, - s9pk: { - url: 'https://github.com/Start9Labs/btc-rpc-proxy-startos/releases/download/v0.3.2.7.1/btc-rpc-proxy.s9pk', - commitment: mockMerkleArchiveCommitment, - signatures: {}, - publishedAt: Date.now().toString(), - }, - }, - }, - categories: ['bitcoin'], - otherVersions: { - '0.3.2.7:0': { - releaseNotes: 'Upstream release and minor fixes.', - }, - }, - }, - }, } export const RegistryPackages: GetPackagesRes = { @@ -857,11 +808,7 @@ export namespace Mock { }, }, categories: ['bitcoin'], - otherVersions: { - '0.3.2.6:0': { - releaseNotes: 'Upstream release and minor fixes.', - }, - }, + otherVersions: {}, }, } @@ -891,7 +838,7 @@ export namespace Mock { id: 2, packageId: null, createdAt: '2019-12-26T14:20:30.872Z', - code: 2, + code: 0, level: 'warning', title: 'SSH Key Added', message: 'A new SSH key was added. If you did not do this, shit is bad.', @@ -902,7 +849,7 @@ export namespace Mock { id: 3, packageId: null, createdAt: '2019-12-26T14:20:30.872Z', - code: 3, + code: 0, level: 'info', title: 'SSH Key Removed', message: 'A SSH key was removed.', @@ -913,7 +860,7 @@ export namespace Mock { id: 4, packageId: 'bitcoind', createdAt: '2019-12-26T14:20:30.872Z', - code: 4, + code: 0, level: 'error', title: 'Service Crashed', message: new Array(3) @@ -1339,7 +1286,7 @@ export namespace Mock { result: { type: 'single', copyable: true, - qr: true, + qr: false, masked: true, value: 'iwejdoiewdhbew', }, diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index 9db729445..72f36b33d 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -335,6 +335,12 @@ export namespace RR { } // package.action.run export type ActionRes = (T.ActionResult & { version: '1' }) | null + export type ClearTaskReq = { + packageId: string + replayId: string + } // package.action.clear-task + export type ClearTaskRes = null + export type RestorePackagesReq = { // package.backup.restore ids: string[] @@ -356,7 +362,11 @@ export namespace RR { export type RebuildPackageReq = { id: string } // package.rebuild export type RebuildPackageRes = null - export type UninstallPackageReq = { id: string } // package.uninstall + export type UninstallPackageReq = { + id: string + force: boolean + soft: boolean + } // package.uninstall export type UninstallPackageRes = null export type SideloadPackageReq = { diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index 0730b9d3d..98ac3a021 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -120,6 +120,8 @@ export abstract class ApiService { abstract repairDisk(params: RR.DiskRepairReq): Promise + abstract toggleKiosk(enable: boolean): Promise + abstract resetTor(params: RR.ResetTorReq): Promise // @TODO 041 @@ -335,6 +337,8 @@ export abstract class ApiService { abstract runAction(params: RR.ActionReq): Promise + abstract clearTask(params: RR.ClearTaskReq): Promise + abstract restorePackages( params: RR.RestorePackagesReq, ): Promise diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index c848d16b0..8bd4b7b4b 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -261,6 +261,13 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'disk.repair', params }) } + async toggleKiosk(enable: boolean): Promise { + return this.rpcRequest({ + method: enable ? 'kiosk.enable' : 'kiosk.disable', + params: {}, + }) + } + async resetTor(params: RR.ResetTorReq): Promise { return this.rpcRequest({ method: 'net.tor.reset', params }) } @@ -577,6 +584,10 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'package.action.run', params }) } + async clearTask(params: RR.ClearTaskReq): Promise { + return this.rpcRequest({ method: 'package.action.clear-task', params }) + } + async restorePackages( params: RR.RestorePackagesReq, ): Promise { diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index a5ecda239..ba212939a 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -22,11 +22,7 @@ import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs' import { mockPatchData } from './mock-patch' import { AuthService } from '../auth.service' import { T } from '@start9labs/start-sdk' -import { - GetPackageRes, - GetPackagesRes, - MarketplacePkg, -} from '@start9labs/marketplace' +import { MarketplacePkg } from '@start9labs/marketplace' import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md' import { WebSocketSubject } from 'rxjs/webSocket' import { toAcmeUrl } from 'src/app/utils/acme' @@ -166,7 +162,6 @@ export class MockApiService extends ApiService { pathArr: Array, value: T, ): Promise { - console.warn(pathArr, value) const pointer = pathFromArray(pathArr) const params: RR.SetDBValueReq = { pointer, value } await pauseFor(2000) @@ -449,6 +444,21 @@ export class MockApiService extends ApiService { return null } + async toggleKiosk(enable: boolean): Promise { + await pauseFor(2000) + + const patch = [ + { + op: PatchOp.REPLACE, + path: '/serverInfo/kiosk', + value: enable, + }, + ] + this.mockRevision(patch) + + return null + } + async resetTor(params: RR.ResetTorReq): Promise { await pauseFor(2000) return null @@ -1103,23 +1113,32 @@ export class MockApiService extends ApiService { async runAction(params: RR.ActionReq): Promise { await pauseFor(2000) - if (params.actionId === 'properties') { - // return Mock.ActionResGroup - return Mock.ActionResMessage - // return Mock.ActionResSingle - } else if (params.actionId === 'config') { - const patch: RemoveOperation[] = [ - { - op: PatchOp.REMOVE, - path: `/packageData/${params.packageId}/requestedActions/${params.packageId}-config`, - }, - ] - this.mockRevision(patch) - return null - } else { - return Mock.ActionResMessage - // return Mock.ActionResSingle - } + const patch: ReplaceOperation<{ [key: string]: T.TaskEntry }>[] = [ + { + op: PatchOp.REPLACE, + path: `/packageData/${params.packageId}/tasks`, + value: {}, + }, + ] + this.mockRevision(patch) + + // return Mock.ActionResGroup + return Mock.ActionResMessage + // return Mock.ActionResSingle + } + + async clearTask(params: RR.ClearTaskReq): Promise { + await pauseFor(2000) + + const patch: RemoveOperation[] = [ + { + op: PatchOp.REMOVE, + path: `/packageData/${params.packageId}/tasks/${params.replayId}`, + }, + ] + this.mockRevision(patch) + + return null } async restorePackages( diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index d30b2742f..3f75bf850 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -183,13 +183,7 @@ export const mockPatchData: DataModel = { pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m', caFingerprint: '63:2B:11:99:44:40:17:DF:37:FC:C3:DF:0F:3D:15', ntpSynced: false, - smtp: { - server: '', - port: 587, - from: '', - login: '', - password: '', - }, + smtp: null, platform: 'x86_64-nonfree', zram: true, governor: 'performance', @@ -221,7 +215,7 @@ export const mockPatchData: DataModel = { actions: { config: { name: 'Set Config', - description: 'edit bitcoin.conf', + description: 'edit bitcoin.conf, soo cool!', warning: null, visibility: 'enabled', allowedStatuses: 'any', diff --git a/web/projects/ui/src/app/services/client-storage.service.ts b/web/projects/ui/src/app/services/client-storage.service.ts index 9f4f09da1..c70d6e9dc 100644 --- a/web/projects/ui/src/app/services/client-storage.service.ts +++ b/web/projects/ui/src/app/services/client-storage.service.ts @@ -1,22 +1,18 @@ -import { Injectable } from '@angular/core' +import { inject, Injectable } from '@angular/core' import { ReplaySubject } from 'rxjs' import { StorageService } from './storage.service' const SHOW_DEV_TOOLS = 'SHOW_DEV_TOOLS' -const SHOW_DISK_REPAIR = 'SHOW_DISK_REPAIR' @Injectable({ providedIn: 'root', }) export class ClientStorageService { + private readonly storage = inject(StorageService) readonly showDevTools$ = new ReplaySubject(1) - readonly showDiskRepair$ = new ReplaySubject(1) - - constructor(private readonly storage: StorageService) {} init() { this.showDevTools$.next(!!this.storage.get(SHOW_DEV_TOOLS)) - this.showDiskRepair$.next(!!this.storage.get(SHOW_DISK_REPAIR)) } toggleShowDevTools(): boolean { @@ -25,11 +21,4 @@ export class ClientStorageService { this.showDevTools$.next(newVal) return newVal } - - toggleShowDiskRepair(): boolean { - const newVal = !this.storage.get(SHOW_DISK_REPAIR) - this.storage.set(SHOW_DISK_REPAIR, newVal) - this.showDiskRepair$.next(newVal) - return newVal - } } diff --git a/web/projects/ui/src/app/services/marketplace.service.ts b/web/projects/ui/src/app/services/marketplace.service.ts index 57b41a02d..4172bff9c 100644 --- a/web/projects/ui/src/app/services/marketplace.service.ts +++ b/web/projects/ui/src/app/services/marketplace.service.ts @@ -31,7 +31,6 @@ import { import { RR } from 'src/app/services/api/api.types' import { ApiService } from 'src/app/services/api/embassy-api.service' import { DataModel } from 'src/app/services/patch-db/data-model' -import { ClientStorageService } from './client-storage.service' const { start9, community } = defaultRegistries @@ -55,20 +54,6 @@ export class MarketplaceService { ]), ) - // option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL - readonly filteredRegistries$: Observable = combineLatest([ - inject(ClientStorageService).showDevTools$, - this.registries$, - ]).pipe( - map(([devMode, registries]) => - devMode - ? registries - : registries.filter( - ({ url }) => !url.includes('alpha') && !url.includes('beta'), - ), - ), - ) - readonly currentRegistryUrl$ = new ReplaySubject(1) readonly requestErrors$ = new BehaviorSubject([]) @@ -252,7 +237,6 @@ export class MarketplaceService { oldName: string | null, newName: string, ): Promise { - console.warn(oldName, newName) if (oldName !== newName) { this.api.setDbValue(['registries', url], newName) } diff --git a/web/projects/ui/src/app/services/notification.service.ts b/web/projects/ui/src/app/services/notification.service.ts index 156a5fa6e..270ae4f10 100644 --- a/web/projects/ui/src/app/services/notification.service.ts +++ b/web/projects/ui/src/app/services/notification.service.ts @@ -93,7 +93,7 @@ export class NotificationService { { data, createdAt, code, title, message }: ServerNotification, full = false, ) { - const label = full || code === 2 ? title : 'Backup Report' + const label = code === 1 ? 'Backup Report' : title const component = code === 1 ? REPORT : MARKDOWN const content = code === 1 ? data : of(data) @@ -104,6 +104,7 @@ export class NotificationService { content, timestamp: createdAt, }, + size: code === 1 ? 'm' : 'l', }) .subscribe() } diff --git a/web/projects/ui/src/app/services/pkg-status-rendering.service.ts b/web/projects/ui/src/app/services/pkg-status-rendering.service.ts index 6050e02e4..d68d38726 100644 --- a/web/projects/ui/src/app/services/pkg-status-rendering.service.ts +++ b/web/projects/ui/src/app/services/pkg-status-rendering.service.ts @@ -1,31 +1,24 @@ -import { PackageDataEntry } from 'src/app/services/patch-db/data-model' -import { PkgDependencyErrors } from './dep-error.service' -import { T } from '@start9labs/start-sdk' import { i18nKey } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' +import { PackageDataEntry } from 'src/app/services/patch-db/data-model' export interface PackageStatus { primary: PrimaryStatus - dependency: DependencyStatus | null health: T.HealthStatus | null } -export function renderPkgStatus( - pkg: PackageDataEntry, - depErrors: PkgDependencyErrors = {}, -): PackageStatus { +export function renderPkgStatus(pkg: PackageDataEntry): PackageStatus { let primary: PrimaryStatus - let dependency: DependencyStatus | null = null let health: T.HealthStatus | null = null if (pkg.stateInfo.state === 'installed') { primary = getInstalledPrimaryStatus(pkg) - dependency = getDependencyStatus(depErrors) health = getHealthStatus(pkg.status) } else { primary = pkg.stateInfo.state } - return { primary, dependency, health } + return { primary, health } } export function getInstalledPrimaryStatus({ @@ -39,10 +32,6 @@ export function getInstalledPrimaryStatus({ : status.main } -function getDependencyStatus(depErrors: PkgDependencyErrors): DependencyStatus { - return Object.values(depErrors).some(err => !!err) ? 'warning' : 'satisfied' -} - function getHealthStatus(status: T.MainStatus): T.HealthStatus | null { if (status.main !== 'running' || !status.main) { return null diff --git a/web/projects/ui/src/app/services/standard-actions.service.ts b/web/projects/ui/src/app/services/standard-actions.service.ts index f6cffcb20..1694cd235 100644 --- a/web/projects/ui/src/app/services/standard-actions.service.ts +++ b/web/projects/ui/src/app/services/standard-actions.service.ts @@ -14,6 +14,7 @@ import { getAllPackages } from '../utils/get-package-data' import { hasCurrentDeps } from '../utils/has-deps' import { ApiService } from './api/embassy-api.service' import { DataModel } from './patch-db/data-model' +import { RR } from './api/api.types' @Injectable({ providedIn: 'root', @@ -40,13 +41,20 @@ export class StandardActionsService { } } - async uninstall({ id, title, alerts }: T.Manifest): Promise { - let content = - alerts.uninstall || - `${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}` + async uninstall( + { id, title, alerts }: T.Manifest, + { force, soft }: { force: boolean; soft: boolean } = { + force: false, + soft: false, + }, + ): Promise { + let content = soft + ? '' + : alerts.uninstall || + `${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}` if (hasCurrentDeps(id, await getAllPackages(this.patch))) { - content = `${content}. ${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}` + content = `${content}${content ? ' ' : ''}${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}` } this.dialog @@ -60,17 +68,15 @@ export class StandardActionsService { }, }) .pipe(filter(Boolean)) - .subscribe(() => this.doUninstall(id)) + .subscribe(() => this.doUninstall({ id, force, soft })) } - private async doUninstall(id: string) { + private async doUninstall(options: RR.UninstallPackageReq) { const loader = this.loader.open('Beginning uninstall').subscribe() try { - await this.api.uninstallPackage({ id }) - await this.api - .setDbValue(['ackInstructions', id], false) - .catch(e => console.error('Failed to mark instructions as unseen', e)) + await this.api.uninstallPackage(options) + await this.api.setDbValue(['ackInstructions', options.id], false) await this.router.navigate(['portal']) } catch (e: any) { this.errorService.handleError(e) diff --git a/web/projects/ui/src/index.html b/web/projects/ui/src/index.html index ba11b1393..c337fbcd3 100644 --- a/web/projects/ui/src/index.html +++ b/web/projects/ui/src/index.html @@ -1,4 +1,4 @@ - + @@ -55,11 +55,7 @@ - Start OS +

Loading

diff --git a/web/projects/ui/src/styles.scss b/web/projects/ui/src/styles.scss index d2dd46692..fd51ef895 100644 --- a/web/projects/ui/src/styles.scss +++ b/web/projects/ui/src/styles.scss @@ -102,6 +102,7 @@ hr { padding: 3.25rem 1rem 0.375rem; border-radius: 0.5rem; overflow: hidden; + color: var(--tui-text-primary); background-color: color-mix(in hsl, var(--start9-base-1) 50%, transparent); background-image: linear-gradient( to bottom,