rework installing page and add cancel install button (#2915)

* rework installing page and add cancel install button

* actually call cancel endpoint

* fix two bugs

* include translations in progress component

* cancellable installs

* fix: comments (#2916)

* fix: comments

* delete comments

* ensure trailing slash and no qp for new registry url

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* fix raspi

* bump sdk

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
This commit is contained in:
Matt Hill
2025-04-30 13:50:08 -06:00
committed by GitHub
parent 5c473eb9cc
commit e6f0067728
37 changed files with 431 additions and 269 deletions

View File

@@ -16,7 +16,7 @@ use models::{ActionId, PackageId};
use reqwest::{Client, Proxy};
use rpc_toolkit::yajrc::RpcError;
use rpc_toolkit::{CallRemote, Context, Empty};
use tokio::sync::{broadcast, watch, Mutex, RwLock};
use tokio::sync::{broadcast, oneshot, watch, Mutex, RwLock};
use tokio::time::Instant;
use tracing::instrument;
@@ -56,6 +56,7 @@ pub struct RpcContextSeed {
pub os_net_service: NetService,
pub s9pk_arch: Option<&'static str>,
pub services: ServiceMap,
pub cancellable_installs: SyncMutex<BTreeMap<PackageId, oneshot::Sender<()>>>,
pub metrics_cache: Watch<Option<crate::system::Metrics>>,
pub shutdown: broadcast::Sender<Option<Shutdown>>,
pub tor_socks: SocketAddr,
@@ -239,6 +240,7 @@ impl RpcContext {
Some(crate::ARCH)
},
services,
cancellable_installs: SyncMutex::new(BTreeMap::new()),
metrics_cache,
shutdown,
tor_socks: tor_proxy,

View File

@@ -154,13 +154,15 @@ pub async fn install(
})?
.s9pk;
let progress_tracker = FullProgressTracker::new();
let download_progress = progress_tracker.add_phase("Downloading".into(), Some(100));
let download = ctx
.services
.install(
ctx.clone(),
|| asset.deserialize_s9pk_buffered(ctx.client.clone()),
|| asset.deserialize_s9pk_buffered(ctx.client.clone(), download_progress),
None::<Never>,
None,
Some(progress_tracker),
)
.await?;
tokio::spawn(async move { download.await?.await });
@@ -188,10 +190,15 @@ pub async fn sideload(
ctx: RpcContext,
SideloadParams { session }: SideloadParams,
) -> Result<SideloadResponse, Error> {
let (upload, file) = upload(&ctx, session.clone()).await?;
let (err_send, mut err_recv) = oneshot::channel::<Error>();
let progress = Guid::new();
let progress_tracker = FullProgressTracker::new();
let (upload, file) = upload(
&ctx,
session.clone(),
progress_tracker.add_phase("Uploading".into(), Some(100)),
)
.await?;
let mut progress_listener = progress_tracker.stream(Some(Duration::from_millis(200)));
ctx.rpc_continuations
.add(
@@ -268,6 +275,24 @@ pub async fn sideload(
Ok(SideloadResponse { upload, progress })
}
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct CancelInstallParams {
pub id: PackageId,
}
#[instrument(skip_all)]
pub fn cancel_install(
ctx: RpcContext,
CancelInstallParams { id }: CancelInstallParams,
) -> Result<(), Error> {
if let Some(cancel) = ctx.cancellable_installs.mutate(|c| c.remove(&id)) {
cancel.send(()).ok();
}
Ok(())
}
#[derive(Deserialize, Serialize, Parser)]
pub struct QueryPackageParams {
id: PackageId,

View File

@@ -349,6 +349,13 @@ pub fn package<C: Context>() -> ParentHandler<C> {
.no_display()
.with_about("Install a package from a marketplace or via sideloading"),
)
.subcommand(
"cancel-install",
from_fn(install::cancel_install)
.no_display()
.with_about("Cancel an install of a package")
.with_call_remote::<CliContext>(),
)
.subcommand(
"uninstall",
from_fn_async(install::uninstall)

View File

@@ -10,6 +10,7 @@ use ts_rs::TS;
use url::Url;
use crate::prelude::*;
use crate::progress::PhaseProgressTrackerHandle;
use crate::registry::signer::commitment::merkle_archive::MerkleArchiveCommitment;
use crate::registry::signer::commitment::{Commitment, Digestable};
use crate::registry::signer::sign::{AnySignature, AnyVerifyingKey};
@@ -75,9 +76,10 @@ impl RegistryAsset<MerkleArchiveCommitment> {
pub async fn deserialize_s9pk_buffered(
&self,
client: Client,
progress: PhaseProgressTrackerHandle,
) -> Result<S9pk<Section<Arc<BufferedHttpSource>>>, Error> {
S9pk::deserialize(
&Arc::new(BufferedHttpSource::new(client, self.url.clone()).await?),
&Arc::new(BufferedHttpSource::new(client, self.url.clone(), progress).await?),
Some(&self.commitment),
)
.await
@@ -89,8 +91,12 @@ pub struct BufferedHttpSource {
file: UploadingFile,
}
impl BufferedHttpSource {
pub async fn new(client: Client, url: Url) -> Result<Self, Error> {
let (mut handle, file) = UploadingFile::new().await?;
pub async fn new(
client: Client,
url: Url,
progress: PhaseProgressTrackerHandle,
) -> Result<Self, Error> {
let (mut handle, file) = UploadingFile::new(progress).await?;
let response = client.get(url).send().await?;
Ok(Self {
_download: tokio::spawn(async move { handle.download(response).await }).into(),

View File

@@ -3,14 +3,14 @@ use std::sync::Arc;
use std::time::Duration;
use color_eyre::eyre::eyre;
use futures::future::BoxFuture;
use futures::future::{BoxFuture, Fuse};
use futures::stream::FuturesUnordered;
use futures::{Future, FutureExt, StreamExt};
use helpers::NonDetachingJoinHandle;
use imbl::OrdMap;
use imbl_value::InternedString;
use models::ErrorData;
use tokio::sync::{Mutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
use tokio::sync::{oneshot, Mutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
use tracing::instrument;
use crate::context::RpcContext;
@@ -138,41 +138,41 @@ impl ServiceMap {
Fut: Future<Output = Result<S9pk<S>, Error>>,
S: FileSource + Clone,
{
let progress = progress.unwrap_or_else(|| FullProgressTracker::new());
let mut validate_progress = progress.add_phase("Validating Headers".into(), Some(1));
let mut unpack_progress = progress.add_phase("Unpacking".into(), Some(100));
let mut s9pk = s9pk().await?;
validate_progress.start();
s9pk.validate_and_filter(ctx.s9pk_arch)?;
validate_progress.complete();
let manifest = s9pk.as_manifest().clone();
let id = manifest.id.clone();
let icon = s9pk.icon_data_url().await?;
let developer_key = s9pk.as_archive().signer();
let mut service = self.get_mut(&id).await;
let size = s9pk.size();
if let Some(size) = size {
unpack_progress.set_total(size);
}
let op_name = if recovery_source.is_none() {
if service.is_none() {
"Install"
"Installing"
} else {
"Update"
"Updating"
}
} else {
"Restore"
"Restoring"
};
let size = s9pk.size();
let progress = progress.unwrap_or_else(|| FullProgressTracker::new());
let download_progress_contribution = size.unwrap_or(60);
let mut download_progress = progress.add_phase(
InternedString::intern("Download"),
Some(download_progress_contribution),
);
if let Some(size) = size {
download_progress.set_total(size);
}
let mut finalization_progress = progress.add_phase(
InternedString::intern(op_name),
Some(download_progress_contribution / 2),
);
let mut finalization_progress = progress.add_phase(op_name.into(), Some(50));
let restoring = recovery_source.is_some();
let mut reload_guard = ServiceRefReloadGuard::new(ctx.clone(), id.clone(), op_name);
let (cancel_send, cancel_recv) = oneshot::channel();
ctx.cancellable_installs
.mutate(|c| c.insert(id.clone(), cancel_send));
let mut reload_guard =
ServiceRefReloadCancelGuard::new(ctx.clone(), id.clone(), op_name, Some(cancel_recv));
reload_guard
.handle(async {
@@ -256,15 +256,15 @@ impl ServiceMap {
Some(Duration::from_millis(100)),
)));
download_progress.start();
unpack_progress.start();
let mut progress_writer = ProgressTrackerWriter::new(
crate::util::io::create_file(&download_path).await?,
download_progress,
unpack_progress,
);
s9pk.serialize(&mut progress_writer, true).await?;
let (file, mut download_progress) = progress_writer.into_inner();
let (file, mut unpack_progress) = progress_writer.into_inner();
file.sync_all().await?;
download_progress.complete();
unpack_progress.complete();
let installed_path = Path::new(DATA_DIR)
.join(PKG_ARCHIVE_DIR)
@@ -339,7 +339,7 @@ impl ServiceMap {
) -> Result<(), Error> {
let mut guard = self.get_mut(id).await;
if let Some(service) = guard.take() {
ServiceRefReloadGuard::new(ctx.clone(), id.clone(), "Uninstall")
ServiceRefReloadCancelGuard::new(ctx.clone(), id.clone(), "Uninstall", None)
.handle_last(async move {
let res = service.uninstall(None, soft, force).await;
drop(guard);
@@ -370,32 +370,51 @@ impl ServiceMap {
}
}
pub struct ServiceRefReloadGuard(Option<ServiceRefReloadInfo>);
impl Drop for ServiceRefReloadGuard {
pub struct ServiceRefReloadCancelGuard(
Option<ServiceRefReloadInfo>,
Option<Fuse<oneshot::Receiver<()>>>,
);
impl Drop for ServiceRefReloadCancelGuard {
fn drop(&mut self) {
if let Some(info) = self.0.take() {
tokio::spawn(info.reload(None));
}
}
}
impl ServiceRefReloadGuard {
pub fn new(ctx: RpcContext, id: PackageId, operation: &'static str) -> Self {
Self(Some(ServiceRefReloadInfo { ctx, id, operation }))
impl ServiceRefReloadCancelGuard {
pub fn new(
ctx: RpcContext,
id: PackageId,
operation: &'static str,
cancel: Option<oneshot::Receiver<()>>,
) -> Self {
Self(
Some(ServiceRefReloadInfo { ctx, id, operation }),
cancel.map(|c| c.fuse()),
)
}
pub async fn handle<T>(
&mut self,
operation: impl Future<Output = Result<T, Error>>,
) -> Result<T, Error> {
let mut errors = ErrorCollection::new();
match operation.await {
let res = async {
if let Some(cancel) = self.1.as_mut() {
tokio::select! {
res = operation => res,
_ = cancel => Err(Error::new(eyre!("Operation Cancelled"), ErrorKind::Cancelled)),
}
} else {
operation.await
}
}.await;
match res {
Ok(a) => Ok(a),
Err(e) => {
if let Some(info) = self.0.take() {
errors.handle(info.reload(Some(e.clone_output())).await);
tokio::spawn(info.reload(Some(e.clone_output())));
}
errors.handle::<(), _>(Err(e));
errors.into_result().map(|_| unreachable!()) // TODO: there's gotta be a more elegant way?
Err(e)
}
}
}

View File

@@ -18,6 +18,7 @@ use tokio::sync::watch;
use crate::context::RpcContext;
use crate::prelude::*;
use crate::progress::PhaseProgressTrackerHandle;
use crate::rpc_continuations::{Guid, RpcContinuation};
use crate::s9pk::merkle_archive::source::multi_cursor_file::{FileCursor, MultiCursorFile};
use crate::s9pk::merkle_archive::source::ArchiveSource;
@@ -26,9 +27,10 @@ use crate::util::io::{create_file, TmpDir};
pub async fn upload(
ctx: &RpcContext,
session: Option<InternedString>,
progress: PhaseProgressTrackerHandle,
) -> Result<(Guid, UploadingFile), Error> {
let guid = Guid::new();
let (mut handle, file) = UploadingFile::new().await?;
let (mut handle, file) = UploadingFile::new(progress).await?;
ctx.rpc_continuations
.add(
guid.clone(),
@@ -50,8 +52,8 @@ pub async fn upload(
Ok((guid, file))
}
#[derive(Default)]
struct Progress {
tracker: PhaseProgressTrackerHandle,
expected_size: Option<u64>,
written: u64,
error: Option<Error>,
@@ -69,6 +71,7 @@ impl Progress {
match res {
Ok(a) => {
self.written += *a as u64;
self.tracker += *a as u64;
true
}
Err(e) => self.handle_error(e),
@@ -123,6 +126,7 @@ impl Progress {
}
}
fn complete(&mut self) -> bool {
self.tracker.complete();
match self {
Self {
expected_size: Some(size),
@@ -133,6 +137,7 @@ impl Progress {
expected_size: Some(size),
written,
error,
..
} if *written > *size && error.is_none() => {
*error = Some(Error::new(
eyre!("Too many bytes received"),
@@ -171,8 +176,13 @@ pub struct UploadingFile {
progress: watch::Receiver<Progress>,
}
impl UploadingFile {
pub async fn new() -> Result<(UploadHandle, Self), Error> {
let progress = watch::channel(Progress::default());
pub async fn new(progress: PhaseProgressTrackerHandle) -> Result<(UploadHandle, Self), Error> {
let progress = watch::channel(Progress {
tracker: progress,
expected_size: None,
written: 0,
error: None,
});
let tmp_dir = Arc::new(TmpDir::new().await?);
let file = create_file(tmp_dir.join("upload.tmp")).await?;
let uploading = Self {
@@ -327,10 +337,12 @@ impl UploadHandle {
self.process_headers(request.headers());
self.process_body(request.into_body().into_data_stream())
.await;
self.progress.send_if_modified(|p| p.complete());
}
pub async fn download(&mut self, response: reqwest::Response) {
self.process_headers(response.headers());
self.process_body(response.bytes_stream()).await;
self.progress.send_if_modified(|p| p.complete());
}
fn process_headers(&mut self, headers: &HeaderMap) {
if let Some(content_length) = headers
@@ -338,8 +350,10 @@ impl UploadHandle {
.and_then(|a| a.to_str().log_err())
.and_then(|a| a.parse::<u64>().log_err())
{
self.progress
.send_modify(|p| p.expected_size = Some(content_length));
self.progress.send_modify(|p| {
p.expected_size = Some(content_length);
p.tracker.set_total(content_length);
});
}
}
async fn process_body<E: Into<Box<(dyn std::error::Error + Send + Sync + 'static)>>>(

View File

@@ -206,8 +206,8 @@ if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then
echo "Configuring raspi kernel '\$v'"
extract-ikconfig "/usr/lib/modules/\$v/kernel/kernel/configs.ko.xz" > /boot/config-\$v
done
mkinitramfs -c gzip -o /boot/initramfs8 6.12.20-v8+
mkinitramfs -c gzip -o /boot/initramfs_2712 6.12.20-v8-16k+
mkinitramfs -c gzip -o /boot/initramfs8 6.12.25-v8+
mkinitramfs -c gzip -o /boot/initramfs_2712 6.12.25-v8-16k+
fi
useradd --shell /bin/bash -G startos -m start9

View File

@@ -25,7 +25,7 @@ import {
import * as patterns from "../../base/lib/util/patterns"
import { BackupSync, Backups } from "./backup/Backups"
import { smtpInputSpec } from "../../base/lib/actions/input/inputSpecConstants"
import { CommandController, Daemons } from "./mainFn/Daemons"
import { CommandController, Daemon, Daemons } from "./mainFn/Daemons"
import { HealthCheck } from "./health/HealthCheck"
import { checkPortListening } from "./health/checkFns/checkPortListening"
import { checkWebUrl, runHealthScript } from "./health/checkFns"
@@ -734,6 +734,11 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
spec: Spec,
) => InputSpec.of<Spec, Store>(spec),
},
Daemon: {
get of() {
return Daemon.of<Manifest>()
},
},
Daemons: {
of(
effects: Effects,

View File

@@ -1,5 +1,6 @@
import * as T from "../../../base/lib/types"
import { asError } from "../../../base/lib/util/asError"
import { Drop } from "../util"
import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer"
import { CommandController } from "./CommandController"
import { Mounts } from "./Mounts"
@@ -11,12 +12,14 @@ const MAX_TIMEOUT_MS = 30000
* and the others state of running, where it will keep a living running command
*/
export class Daemon<Manifest extends T.SDKManifest> {
export class Daemon<Manifest extends T.SDKManifest> extends Drop {
private commandController: CommandController<Manifest> | null = null
private shouldBeRunning = false
constructor(
private startCommand: () => Promise<CommandController<Manifest>>,
) {}
) {
super()
}
get subContainerHandle(): undefined | ExecSpawnable {
return this.commandController?.subContainerHandle
}
@@ -88,4 +91,7 @@ export class Daemon<Manifest extends T.SDKManifest> {
.catch((e) => console.error(asError(e)))
this.commandController = null
}
onDrop(): void {
this.stop().catch((e) => console.error(asError(e)))
}
}

View File

@@ -51,21 +51,26 @@ export type Ready = {
type DaemonsParams<
Manifest extends T.SDKManifest,
Ids extends string,
Command extends string,
Id extends string,
> = {
/** The command line command to start the daemon */
command: T.CommandType
/** Information about the subcontainer in which the daemon runs */
subcontainer: SubContainer<Manifest>
env?: Record<string, string>
ready: Ready
/** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */
requires: Exclude<Ids, Id>[]
sigtermTimeout?: number
onStdout?: (chunk: Buffer | string | any) => void
onStderr?: (chunk: Buffer | string | any) => void
}
> =
| {
/** The command line command to start the daemon */
command: T.CommandType
/** Information about the subcontainer in which the daemon runs */
subcontainer: SubContainer<Manifest>
env?: Record<string, string>
ready: Ready
/** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */
requires: Exclude<Ids, Id>[]
sigtermTimeout?: number
onStdout?: (chunk: Buffer | string | any) => void
onStderr?: (chunk: Buffer | string | any) => void
}
| {
daemon: Daemon<Manifest>
ready: Ready
requires: Exclude<Ids, Id>[]
}
type ErrorDuplicateId<Id extends string> = `The id '${Id}' is already used`
@@ -136,27 +141,23 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
* @param newDaemon
* @returns
*/
addDaemon<Id extends string, Command extends string>(
addDaemon<Id extends string>(
// prettier-ignore
id:
"" extends Id ? never :
ErrorDuplicateId<Id> extends Id ? never :
Id extends Ids ? ErrorDuplicateId<Id> :
Id,
options: DaemonsParams<Manifest, Ids, Command, Id>,
options: DaemonsParams<Manifest, Ids, Id>,
) {
const daemonIndex = this.daemons.length
const daemon = Daemon.of()(
this.effects,
options.subcontainer,
options.command,
{
...options,
},
)
const daemon =
"daemon" in options
? Promise.resolve(options.daemon)
: Daemon.of()(this.effects, options.subcontainer, options.command, {
...options,
})
const healthDaemon = new HealthDaemon(
daemon,
daemonIndex,
options.requires
.map((x) => this.ids.indexOf(x))
.filter((x) => x >= 0)
@@ -165,7 +166,6 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
this.ids,
options.ready,
this.effects,
options.sigtermTimeout,
)
const daemons = this.daemons.concat(daemon)
const ids = [...this.ids, id] as (Ids | Id)[]
@@ -184,7 +184,7 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
try {
this.healthChecks.forEach((health) => health.stop())
for (let result of await Promise.allSettled(
this.healthDaemons.map((x) => x.term({ timeout: x.sigtermTimeout })),
this.healthDaemons.map((x) => x.term()),
)) {
if (result.status === "rejected") {
console.error(result.reason)

View File

@@ -30,13 +30,11 @@ export class HealthDaemon<Manifest extends SDKManifest> {
private readyPromise: Promise<void>
constructor(
private readonly daemon: Promise<Daemon<Manifest>>,
readonly daemonIndex: number,
private readonly dependencies: HealthDaemon<Manifest>[],
readonly id: string,
readonly ids: string[],
readonly ready: Ready,
readonly effects: Effects,
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
) {
this.readyPromise = new Promise((resolve) => (this.resolveReady = resolve))
this.dependencies.forEach((d) => d.addWatcher(() => this.updateStatus()))
@@ -53,7 +51,6 @@ export class HealthDaemon<Manifest extends SDKManifest> {
await this.daemon.then((d) =>
d.term({
timeout: this.sigtermTimeout,
...termOptions,
}),
)

View File

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

View File

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

View File

@@ -55,7 +55,7 @@ export class AppComponent {
)
.subscribe({
complete: async () => {
const loader = this.loader.open('' as i18nKey).subscribe()
const loader = this.loader.open().subscribe()
try {
await this.api.reboot()

View File

@@ -17,7 +17,6 @@ import { StoreIconComponentModule } from './store-icon/store-icon.component.modu
<ng-content />
}
`,
styles: [':host { border-radius: 0.25rem; width: stretch; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [StoreIconComponentModule, TuiIcon, TuiTitle],
})

View File

@@ -25,7 +25,7 @@ export type StoreIdentity = {
name: string
}
export type Marketplace = Record<string, StoreData | null>
export type Marketplace = Record<string, StoreDataWithUrl | null>
export type StoreData = {
info: T.RegistryInfo

View File

@@ -362,8 +362,8 @@ export default {
359: 'Die Partition enthält keine gültige Sicherung',
360: 'Sicherungsfortschritt',
361: 'Abgeschlossen',
362: 'Sicherung läuft',
363: 'Warten',
362: 'sicherung läuft',
363: 'warten',
364: 'Sicherung erstellt',
365: 'Wiederherstellung ausgewählt',
366: 'Initialisierung',
@@ -493,4 +493,9 @@ export default {
490: 'deutsch',
491: 'englisch',
492: 'Startmenü',
493: 'Installationsfortschritt',
494: 'Herunterladen',
495: 'Validierung',
496: 'in Bearbeitung',
497: 'abgeschlossen',
} satisfies i18n

View File

@@ -361,8 +361,8 @@ export const ENGLISH = {
'Drive partition does not contain a valid backup': 359,
'Backup Progress': 360,
'Complete': 361,
'Backing up': 362,
'Waiting': 363,
'backing up': 362,
'waiting': 363,
'Backup made': 364,
'Restore selected': 365,
'Initializing': 366,
@@ -492,4 +492,9 @@ export const ENGLISH = {
'german': 490,
'english': 491,
'Start Menu': 492,
'Install Progress': 493,
'Downloading': 494,
'Validating': 495,
'in progress': 496,
'complete': 497,
} as const

View File

@@ -362,8 +362,8 @@ export default {
359: 'La partición de la unidad no contiene una copia de seguridad válida',
360: 'Progreso de la copia de seguridad',
361: 'Completo',
362: 'Haciendo copia de seguridad',
363: 'Esperando',
362: 'haciendo copia de seguridad',
363: 'esperando',
364: 'Copia de seguridad realizada',
365: 'Restauración seleccionada',
366: 'Inicializando',
@@ -493,4 +493,9 @@ export default {
490: 'alemán',
491: 'inglés',
492: 'Menú de Inicio',
} as any satisfies i18n
493: 'Progreso de instalación',
494: 'Descargando',
495: 'Validando',
496: 'en progreso',
497: 'completo',
} satisfies i18n

View File

@@ -362,8 +362,8 @@ export default {
359: 'Partycja dysku nie zawiera prawidłowej kopii zapasowej',
360: 'Postęp tworzenia kopii zapasowej',
361: 'Zakończono',
362: 'Tworzenie kopii zapasowej',
363: 'Oczekiwanie',
362: 'tworzenie kopii zapasowej',
363: 'oczekiwanie',
364: 'Kopia zapasowa utworzona',
365: 'Wybrano przywracanie',
366: 'Inicjalizacja',
@@ -493,4 +493,9 @@ export default {
490: 'niemiecki',
491: 'angielski',
492: 'Menu Startowe',
493: 'Postęp instalacji',
494: 'Pobieranie',
495: 'Weryfikowanie',
496: 'w toku',
497: 'zakończono',
} satisfies i18n

View File

@@ -39,7 +39,7 @@ class LoadingComponent {
useFactory: () => new LoadingService(TUI_DIALOGS, LoadingComponent),
})
export class LoadingService extends TuiPopoverService<unknown> {
override open<G = void>(textContent: i18nKey) {
override open<G = void>(textContent: i18nKey | '' = '') {
return super.open<G>(textContent)
}
}

View File

@@ -127,7 +127,7 @@ export class MarketplaceControlsComponent {
async tryInstall() {
const currentUrl = this.file
? null
: await firstValueFrom(this.marketplaceService.getCurrentRegistryUrl$())
: await firstValueFrom(this.marketplaceService.currentRegistryUrl$)
const originalUrl = this.localPkg?.registry || null
if (!this.localPkg) {

View File

@@ -53,8 +53,7 @@ import { DialogService, i18nPipe } from '@start9labs/shared'
})
export class MarketplaceMenuComponent {
private readonly dialog = inject(DialogService)
private readonly marketplaceService = inject(MarketplaceService)
readonly registry$ = this.marketplaceService.getCurrentRegistry$()
readonly registry$ = inject(MarketplaceService).currentRegistry$
changeRegistry() {
this.dialog

View File

@@ -29,9 +29,7 @@ import { StorageService } from 'src/app/services/storage.service'
<div class="marketplace-content-inner">
<marketplace-notification [url]="(url$ | async) || ''" />
<div class="title-wrapper">
<h1>
{{ category$ | async | titlecase }}
</h1>
<h1>{{ category$ | async | titlecase }}</h1>
</div>
@if (registry$ | async; as registry) {
<section class="marketplace-content-list">
@@ -178,14 +176,14 @@ export default class MarketplaceComponent {
queryParamsHandling: 'merge',
})
} else {
this.marketplaceService.setRegistryUrl(registry)
this.marketplaceService.currentRegistryUrl$.next(registry)
}
}),
)
.subscribe()
readonly url$ = this.marketplaceService.getCurrentRegistryUrl$()
readonly url$ = this.marketplaceService.currentRegistryUrl$
readonly category$ = this.categoryService.getCategory$()
readonly query$ = this.categoryService.getQuery$()
readonly registry$ = this.marketplaceService.getCurrentRegistry$()
readonly registry$ = this.marketplaceService.currentRegistry$
}

View File

@@ -194,7 +194,7 @@ export class MarketplacePreviewComponent {
readonly flavors$ = this.flavor$.pipe(
switchMap(current =>
this.marketplaceService.getCurrentRegistry$().pipe(
this.marketplaceService.currentRegistry$.pipe(
map(({ packages }) =>
packages.filter(
({ id, flavor }) => id === this.pkgId && flavor !== current,

View File

@@ -41,7 +41,7 @@ import { StorageService } from 'src/app/services/storage.service'
></button>
}
<h3 class="g-title">{{ 'Custom Registries' | i18n }}</h3>
<button tuiCell (click)="add()" [style.width]="'-webkit-fill-available'">
<button tuiCell (click)="add()">
<tui-icon icon="@tui.plus" [style.margin-inline.rem]="'0.5'" />
<div tuiTitle>{{ 'Add custom registry' | i18n }}</div>
</button>
@@ -71,6 +71,10 @@ import { StorageService } from 'src/app/services/storage.service'
flex-direction: row;
align-items: center;
}
[tuiCell] {
width: stretch;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -102,8 +106,8 @@ export class MarketplaceRegistryModal {
private readonly storage = inject(StorageService)
readonly registries$ = combineLatest([
this.marketplaceService.getRegistries$(),
this.marketplaceService.getCurrentRegistryUrl$(),
this.marketplaceService.registries$,
this.marketplaceService.currentRegistryUrl$,
]).pipe(
map(([registries, currentUrl]) =>
registries.map(s => ({
@@ -185,7 +189,7 @@ export class MarketplaceRegistryModal {
loader.closed = false
loader.add(this.loader.open('Changing registry').subscribe())
try {
this.marketplaceService.setRegistryUrl(url)
this.marketplaceService.currentRegistryUrl$.next(url)
this.router.navigate([], {
queryParams: { registry: url },
queryParamsHandling: 'merge',
@@ -231,7 +235,7 @@ export class MarketplaceRegistryModal {
private async save(rawUrl: string, connect = false): Promise<boolean> {
const loader = this.loader.open('Loading').subscribe()
const url = new URL(rawUrl).toString()
const url = new URL(rawUrl).origin + '/'
try {
await this.validateAndSave(url, loader)

View File

@@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core'
import { MarketplacePkgBase } from '@start9labs/marketplace'
import { DialogService, i18nKey, i18nPipe, sameUrl } from '@start9labs/shared'
import { defaultIfEmpty, firstValueFrom } from 'rxjs'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { MarketplaceService } from 'src/app/services/marketplace.service'
@Injectable({
@@ -16,14 +16,12 @@ export class MarketplaceAlertsService {
url: string,
originalUrl: string | null,
): Promise<boolean> {
const registries = await firstValueFrom(
this.marketplaceService.getRegistries$(),
)
const registries = await firstValueFrom(this.marketplaceService.registries$)
const message = originalUrl
? `${this.i18n.transform('installed from')} ${registries.find(h => h.url === originalUrl) || originalUrl}`
? `${this.i18n.transform('installed from')} ${registries.find(r => sameUrl(r.url, originalUrl))?.name || originalUrl}`
: this.i18n.transform('sideloaded')
const currentName = registries.find(h => h.url === url) || url
const currentName = registries.find(h => sameUrl(h.url, url))?.name || url
return new Promise(async resolve => {
this.dialog

View File

@@ -1,31 +1,107 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiProgress } from '@taiga-ui/kit'
import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
@Component({
selector: '[progress]',
selector: 'service-install-progress',
template: `
<ng-content />
@if (progress | installingProgress; as percent) {
: {{ percent }}%
<progress
tuiProgressBar
<header>
{{ 'Install Progress' | i18n }}
<button
tuiButton
size="xs"
[style.color]="
progress === true
? 'var(--tui-text-positive)'
: 'var(--tui-text-action)'
"
[value]="percent / 100"
></progress>
appearance="primary-destructive"
[style.margin-inline-start]="'auto'"
(click)="cancel()"
>
{{ 'Cancel' | i18n }}
</button>
</header>
@for (
phase of pkg.stateInfo.installingInfo?.progress?.phases;
track $index
) {
<div *tuiLet="phase.progress | installingProgress as percent">
{{ $any(phase.name) | i18n }}:
@if (phase.progress === null) {
<span>{{ 'waiting' | i18n }}</span>
} @else if (phase.progress === true) {
<span>{{ 'complete' | i18n }}!</span>
} @else if (phase.progress === false || phase.progress.total === null) {
<span>{{ 'in progress' | i18n }}...</span>
} @else {
<span>{{ percent }}%</span>
}
<progress
tuiProgressBar
size="m"
[max]="100"
[class.g-positive]="phase.progress === true"
[value]="isPending(phase.progress) ? undefined : percent"
></progress>
</div>
}
`,
styles: [':host { line-height: 2rem }'],
styles: `
:host {
grid-column: span 6;
color: var(--tui-text-secondary);
}
div {
padding: 0.25rem 0;
}
span {
float: right;
text-transform: capitalize;
}
progress {
margin: 0.5rem 0;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiProgress, InstallingProgressPipe],
imports: [TuiProgress, TuiLet, InstallingProgressPipe, i18nPipe, TuiButton],
})
export class ServiceProgressComponent {
@Input({ required: true }) progress!: T.Progress
export class ServiceInstallProgressComponent {
@Input({ required: true })
pkg!: PackageDataEntry
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
isPending(progress: T.Progress): boolean {
return (
!progress || (progress && progress !== true && progress.total === null)
)
}
async cancel() {
const loader = this.loader.open().subscribe()
try {
await this.api.cancelInstallPackage({ id: getManifest(this.pkg).id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -18,9 +18,7 @@ import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
@if (loading) {
<tui-loader size="s" />
} @else {
@if (healthy) {
<tui-icon icon="@tui.check" class="g-positive" />
} @else {
@if (!healthy) {
<tui-icon icon="@tui.triangle-alert" class="g-warning" />
}
}

View File

@@ -25,46 +25,45 @@ import { ServiceDependenciesComponent } from '../components/dependencies.compone
import { ServiceErrorComponent } from '../components/error.component'
import { ServiceHealthChecksComponent } from '../components/health-checks.component'
import { ServiceInterfacesComponent } from '../components/interfaces.component'
import { ServiceProgressComponent } from '../components/progress.component'
import { ServiceInstallProgressComponent } from '../components/progress.component'
import { ServiceStatusComponent } from '../components/status.component'
@Component({
template: `
<service-status
[connected]="!!connected()"
[installingInfo]="pkg()?.stateInfo?.installingInfo"
[status]="status()"
>
@if ($any(pkg()?.status)?.started; as started) {
<p class="g-secondary" [appUptime]="started"></p>
}
@if (installed() && connected() && pkg(); as pkg) {
<service-controls [pkg]="pkg" [status]="status()" />
}
</service-status>
@if (pkg(); as pkg) {
@if (installing()) {
<service-install-progress [pkg]="pkg" />
} @else if (installed()) {
<service-status
[connected]="!!connected()"
[installingInfo]="pkg.stateInfo.installingInfo"
[status]="status()"
>
@if ($any(pkg.status)?.started; as started) {
<p class="g-secondary" [appUptime]="started"></p>
}
@if (installed() && pkg(); as pkg) {
@if (pkg.status.main === 'error') {
<service-error [pkg]="pkg" />
}
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
@if (errors() | async; as errors) {
<service-dependencies
[pkg]="pkg"
[services]="services()"
[errors]="errors"
/>
}
<service-health-checks [checks]="health()" />
<service-action-requests [pkg]="pkg" [services]="services() || {}" />
}
@if (connected()) {
<service-controls [pkg]="pkg" [status]="status()" />
}
</service-status>
@if (installing() && pkg(); as pkg) {
@for (
item of pkg.stateInfo.installingInfo?.progress?.phases;
track $index
) {
<p [progress]="item.progress">{{ item.name }}</p>
@if (pkg.status.main === 'error') {
<service-error [pkg]="pkg" />
}
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
@if (errors() | async; as errors) {
<service-dependencies
[pkg]="pkg"
[services]="services()"
[errors]="errors"
/>
}
<service-health-checks [checks]="health()" />
<service-action-requests [pkg]="pkg" [services]="services() || {}" />
}
}
`,
@@ -94,7 +93,7 @@ import { ServiceStatusComponent } from '../components/status.component'
standalone: true,
imports: [
CommonModule,
ServiceProgressComponent,
ServiceInstallProgressComponent,
ServiceStatusComponent,
ServiceControlsComponent,
ServiceInterfacesComponent,

View File

@@ -27,13 +27,13 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
<span tuiSubtitle>
@if (progress.complete) {
<tui-icon icon="@tui.check" class="g-positive" />
{{ 'Complete' | i18n }}
{{ 'complete' | i18n }}
} @else {
@if ((pkg.key | tuiMapper: toStatus | async) === 'backingUp') {
<tui-loader size="s" />
{{ 'Backing up' | i18n }}
{{ 'backing up' | i18n }}
} @else {
{{ 'Waiting' | i18n }}...
{{ 'waiting' | i18n }}
}
}
</span>

View File

@@ -224,14 +224,12 @@ export default class UpdatesComponent {
readonly data = toSignal<UpdatesData>(
combineLatest({
hosts: this.marketplaceService
.getRegistries$(true)
.pipe(
tap(
([registry]) =>
!this.isMobile && registry && this.current.set(registry),
),
hosts: this.marketplaceService.filteredRegistries$.pipe(
tap(
([registry]) =>
!this.isMobile && registry && this.current.set(registry),
),
),
marketplace: this.marketplaceService.marketplace$,
localPkgs: inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData')
@@ -248,7 +246,7 @@ export default class UpdatesComponent {
),
),
),
errors: this.marketplaceService.getRequestErrors$(),
errors: this.marketplaceService.requestErrors$,
}),
)

View File

@@ -319,6 +319,9 @@ export namespace RR {
export type InstallPackageReq = T.InstallParams
export type InstallPackageRes = null
export type CancelInstallPackageReq = { id: string }
export type CancelInstallPackageRes = null
export type GetActionInputReq = { packageId: string; actionId: string } // package.action.get-input
export type GetActionInputRes = {
spec: IST.InputSpec

View File

@@ -325,6 +325,10 @@ export abstract class ApiService {
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes>
abstract cancelInstallPackage(
params: RR.CancelInstallPackageReq,
): Promise<RR.CancelInstallPackageRes>
abstract getActionInput(
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes>

View File

@@ -560,6 +560,12 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.install', params })
}
async cancelInstallPackage(
params: RR.CancelInstallPackageReq,
): Promise<RR.CancelInstallPackageRes> {
return this.rpcRequest({ method: 'package.cancel-install', params })
}
async getActionInput(
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes> {

View File

@@ -50,10 +50,7 @@ const PROGRESS: T.FullProgress = {
},
{
name: 'Installing',
progress: {
done: 0,
total: 40,
},
progress: null,
},
],
}
@@ -1077,6 +1074,22 @@ export class MockApiService extends ApiService {
return null
}
async cancelInstallPackage(
params: RR.CancelInstallPackageReq,
): Promise<RR.CancelInstallPackageRes> {
await pauseFor(500)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.id}`,
},
]
this.mockRevision(patch)
return null
}
async getActionInput(
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes> {

View File

@@ -1,13 +1,12 @@
import { Injectable } from '@angular/core'
import { inject, Injectable } from '@angular/core'
import {
GetPackageRes,
Marketplace,
MarketplacePkg,
StoreData,
StoreDataWithUrl,
StoreIdentity,
} from '@start9labs/marketplace'
import { Exver, defaultRegistries, sameUrl } from '@start9labs/shared'
import { defaultRegistries, Exver, sameUrl } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import {
@@ -40,29 +39,11 @@ const { start9, community } = defaultRegistries
providedIn: 'root',
})
export class MarketplaceService {
private readonly currentRegistryUrlSubject$ = new ReplaySubject<string>(1)
private readonly currentRegistryUrl$ = this.currentRegistryUrlSubject$.pipe(
distinctUntilChanged(),
)
private readonly api = inject(ApiService)
private readonly patch: PatchDB<DataModel> = inject(PatchDB)
private readonly exver = inject(Exver)
private readonly currentRegistry$: Observable<StoreDataWithUrl> =
this.currentRegistryUrl$.pipe(
switchMap(url => this.fetchRegistry$(url)),
filter(Boolean),
map(registry => {
registry.info.categories = {
all: {
name: 'All',
},
...registry.info.categories,
}
return registry
}),
shareReplay(1),
)
private readonly registries$: Observable<StoreIdentity[]> = this.patch
readonly registries$: Observable<StoreIdentity[]> = this.patch
.watch$('ui', 'registries')
.pipe(
map(registries => [
@@ -74,21 +55,23 @@ export class MarketplaceService {
]),
)
private readonly filteredRegistries$: Observable<StoreIdentity[]> =
combineLatest([
this.clientStorageService.showDevTools$,
this.registries$,
]).pipe(
map(([devMode, registries]) =>
devMode
? registries
: registries.filter(
({ url }) => !url.includes('alpha') && !url.includes('beta'),
),
),
)
// option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL
readonly filteredRegistries$: Observable<StoreIdentity[]> = combineLatest([
inject(ClientStorageService).showDevTools$,
this.registries$,
]).pipe(
map(([devMode, registries]) =>
devMode
? registries
: registries.filter(
({ url }) => !url.includes('alpha') && !url.includes('beta'),
),
),
)
private readonly requestErrors$ = new BehaviorSubject<string[]>([])
readonly currentRegistryUrl$ = new ReplaySubject<string>(1)
readonly requestErrors$ = new BehaviorSubject<string[]>([])
readonly marketplace$: Observable<Marketplace> = this.registries$.pipe(
startWith<StoreIdentity[]>([]),
@@ -102,11 +85,11 @@ export class MarketplaceService {
if (data?.info.name)
this.updateRegistryName(url, name, data.info.name)
}),
map<StoreData | null, [string, StoreData | null]>(data => [url, data]),
startWith<[string, StoreData | null]>([url, null]),
map(data => [url, data] satisfies [string, StoreDataWithUrl | null]),
startWith<[string, StoreDataWithUrl | null]>([url, null]),
),
),
scan<[string, StoreData | null], Record<string, StoreData | null>>(
scan<[string, StoreDataWithUrl | null], Marketplace>(
(requests, [url, store]) => {
requests[url] = store
@@ -114,32 +97,21 @@ export class MarketplaceService {
},
{},
),
shareReplay({ bufferSize: 1, refCount: true }),
shareReplay(1),
)
constructor(
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly clientStorageService: ClientStorageService,
private readonly exver: Exver,
) {}
getRegistries$(filtered = false): Observable<StoreIdentity[]> {
// option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL
return filtered ? this.filteredRegistries$ : this.registries$
}
getCurrentRegistryUrl$() {
return this.currentRegistryUrl$
}
setRegistryUrl(url: string) {
this.currentRegistryUrlSubject$.next(url)
}
getCurrentRegistry$(): Observable<StoreDataWithUrl> {
return this.currentRegistry$
}
readonly currentRegistry$: Observable<StoreDataWithUrl> = combineLatest([
this.marketplace$,
this.currentRegistryUrl$,
this.currentRegistryUrl$.pipe(
distinctUntilChanged(),
switchMap(url => this.fetchRegistry$(url).pipe(startWith(null))),
),
]).pipe(
map(([all, url, current]) => current || all[url]),
filter(Boolean),
shareReplay(1),
)
getPackage$(
id: string,
@@ -161,14 +133,12 @@ export class MarketplaceService {
)
}
fetchInfo$(url: string): Observable<T.RegistryInfo> {
return from(this.api.getRegistryInfo({ registry: url })).pipe(
fetchInfo$(registry: string): Observable<T.RegistryInfo> {
return from(this.api.getRegistryInfo({ registry })).pipe(
map(info => ({
...info,
categories: {
all: {
name: 'All',
},
all: { name: 'All' },
...info.categories,
},
})),
@@ -263,10 +233,6 @@ export class MarketplaceService {
}
}
getRequestErrors$(): Observable<string[]> {
return this.requestErrors$
}
async installPackage(
id: string,
version: string,