mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)>>>(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
4
sdk/package/package-lock.json
generated
4
sdk/package/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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$
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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$,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user