Convert properties to an action (#2751)

* update actions response types and partially implement in UI

* further remove diagnostic ui

* convert action response nested to array

* prepare action res modal for Alex

* ad dproperties action for Bitcoin

* feat: add action success dialog (#2753)

* feat: add action success dialog

* mocks for string action res and hide properties from actions page

---------

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

* return null

* remove properties from backend

* misc fixes

* make severity separate argument

* rename ActionRequest to ActionRequestOptions

* add clearRequests

* fix s9pk build

* remove config and properties, introduce action requests

* better ux, better moocks, include icons

* fix dependency types

* add variant for versionCompat

* fix dep icon display and patch operation display

* misc fixes

* misc fixes

* alpha 12

* honor provided input to set values in action

* fix: show full descriptions of action success items (#2758)

* fix type

* fix: fix build:deps command on Windows (#2752)

* fix: fix build:deps command on Windows

* fix: add escaped quotes

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>

* misc db compatibility fixes

---------

Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>
This commit is contained in:
Matt Hill
2024-10-17 13:31:56 -06:00
committed by GitHub
parent fb074c8c32
commit 2ba56b8c59
105 changed files with 1385 additions and 1578 deletions

View File

@@ -150,15 +150,15 @@ export function makeEffects(context: EffectContext): Effects {
stack: new Error().stack,
}) as ReturnType<T.Effects["bind"]>
},
clearBindings(...[]: Parameters<T.Effects["clearBindings"]>) {
return rpcRound("clear-bindings", {}) as ReturnType<
clearBindings(...[options]: Parameters<T.Effects["clearBindings"]>) {
return rpcRound("clear-bindings", { ...options }) as ReturnType<
T.Effects["clearBindings"]
>
},
clearServiceInterfaces(
...[]: Parameters<T.Effects["clearServiceInterfaces"]>
...[options]: Parameters<T.Effects["clearServiceInterfaces"]>
) {
return rpcRound("clear-service-interfaces", {}) as ReturnType<
return rpcRound("clear-service-interfaces", { ...options }) as ReturnType<
T.Effects["clearServiceInterfaces"]
>
},

View File

@@ -42,6 +42,7 @@ export const matchRpcResult = anyOf(
),
}),
)
export type RpcResult = typeof matchRpcResult._TYPE
type SocketResponse = ({ jsonrpc: "2.0"; id: IdType } & RpcResult) | null
@@ -88,7 +89,7 @@ const sandboxRunType = object(
const callbackType = object({
method: literal("callback"),
params: object({
callback: number,
id: number,
args: array,
}),
})
@@ -288,8 +289,8 @@ export class RpcListener {
return handleRpc(id, result)
})
.when(callbackType, async ({ params: { callback, args } }) => {
this.callCallback(callback, args)
.when(callbackType, async ({ params: { id, args } }) => {
this.callCallback(id, args)
return null
})
.when(startType, async ({ id }) => {
@@ -410,7 +411,7 @@ export class RpcListener {
input: any,
) {
const ensureResultTypeShape = (
result: void | T.ActionInput | T.PropertiesReturn | T.ActionResult | null,
result: void | T.ActionInput | T.ActionResult | null,
): { result: any } => {
if (isResult(result)) return result
return { result }
@@ -428,8 +429,6 @@ export class RpcListener {
return system.createBackup(effects, timeout || null)
case "/backup/restore":
return system.restoreBackup(effects, timeout || null)
case "/properties":
return system.properties(effects, timeout || null)
case "/packageInit":
return system.packageInit(effects, timeout || null)
case "/packageUninit":

View File

@@ -135,6 +135,34 @@ type OldGetConfigRes = {
spec: OldConfigSpec
}
export type PropertiesValue =
| {
/** The type of this value, either "string" or "object" */
type: "object"
/** A nested mapping of values. The user will experience this as a nested page with back button */
value: { [k: string]: PropertiesValue }
/** (optional) A human readable description of the new set of values */
description: string | null
}
| {
/** The type of this value, either "string" or "object" */
type: "string"
/** The value to display to the user */
value: string
/** A human readable description of the value */
description: string | null
/** Whether or not to mask the value, for example, when displaying a password */
masked: boolean | null
/** Whether or not to include a button for copying the value to clipboard */
copyable: boolean | null
/** Whether or not to include a button for displaying the value as a QR code */
qr: boolean | null
}
export type PropertiesReturn = {
[key: string]: PropertiesValue
}
export type PackagePropertiesV2 = {
[name: string]: PackagePropertyObject | PackagePropertyString
}
@@ -157,7 +185,7 @@ export type PackagePropertyObject = {
const asProperty_ = (
x: PackagePropertyString | PackagePropertyObject,
): T.PropertiesValue => {
): PropertiesValue => {
if (x.type === "object") {
return {
...x,
@@ -177,7 +205,7 @@ const asProperty_ = (
...x,
}
}
const asProperty = (x: PackagePropertiesV2): T.PropertiesReturn =>
const asProperty = (x: PackagePropertiesV2): PropertiesReturn =>
Object.fromEntries(
Object.entries(x).map(([key, value]) => [key, asProperty_(value)]),
)
@@ -214,6 +242,31 @@ const matchProperties = object({
data: matchPackageProperties,
})
function convertProperties(
name: string,
value: PropertiesValue,
): T.ActionResultV1 {
if (value.type === "string") {
return {
type: "string",
name,
description: value.description,
copyable: value.copyable || false,
masked: value.masked || false,
qr: value.qr || false,
value: value.value,
}
}
return {
type: "object",
name,
description: value.description || undefined,
value: Object.entries(value.value).map(([name, value]) =>
convertProperties(name, value),
),
}
}
const DEFAULT_REGISTRY = "https://registry.start9.com"
export class SystemForEmbassy implements System {
currentRunning: MainLoop | undefined
@@ -245,6 +298,9 @@ export class SystemForEmbassy implements System {
await this.dependenciesAutoconfig(effects, depId, null)
}
}
await effects.setMainStatus({ status: "stopped" })
await this.exportActions(effects)
await this.exportNetwork(effects)
}
async exit(): Promise<void> {
@@ -281,10 +337,15 @@ export class SystemForEmbassy implements System {
await effects.setDataVersion({
version: ExtendedVersion.parseEmver(this.manifest.version).toString(),
})
} else {
await effects.action.request({
packageId: this.manifest.id,
actionId: "config",
severity: "critical",
replayId: "needs-config",
reason: "This service must be configured before it can be run",
})
}
await effects.setMainStatus({ status: "stopped" })
await this.exportActions(effects)
await this.exportNetwork(effects)
}
async exportNetwork(effects: Effects) {
for (const [id, interfaceValue] of Object.entries(
@@ -375,6 +436,8 @@ export class SystemForEmbassy implements System {
if (actionId === "config") {
const config = await this.getConfig(effects, timeoutMs)
return { spec: config.spec, value: config.config }
} else if (actionId === "properties") {
return null
} else {
const oldSpec = this.manifest.actions?.[actionId]?.["input-spec"]
if (!oldSpec) return null
@@ -393,6 +456,17 @@ export class SystemForEmbassy implements System {
if (actionId === "config") {
await this.setConfig(effects, input, timeoutMs)
return null
} else if (actionId === "properties") {
return {
version: "1",
type: "object",
name: "Properties",
description:
"Runtime information, credentials, and other values of interest",
value: Object.entries(await this.properties(effects, timeoutMs)).map(
([name, value]) => convertProperties(name, value),
),
}
} else {
return this.action(effects, actionId, input, timeoutMs)
}
@@ -405,17 +479,21 @@ export class SystemForEmbassy implements System {
if (manifest.config) {
actions.config = {
name: "Configure",
description: "Edit the configuration of this service",
description: `Customize ${manifest.title}`,
"allowed-statuses": ["running", "stopped"],
"input-spec": {},
implementation: { type: "script", args: [] },
}
await effects.action.request({
packageId: this.manifest.id,
actionId: "config",
replayId: "needs-config",
description: "This service must be configured before it can be run",
})
}
if (manifest.properties) {
actions.properties = {
name: "Properties",
description:
"Runtime information, credentials, and other values of interest",
"allowed-statuses": ["running", "stopped"],
"input-spec": null,
implementation: { type: "script", args: [] },
}
}
for (const [actionId, action] of Object.entries(actions)) {
const hasRunning = !!action["allowed-statuses"].find(
@@ -694,7 +772,7 @@ export class SystemForEmbassy implements System {
async properties(
effects: Effects,
timeoutMs: number | null,
): Promise<T.PropertiesReturn> {
): Promise<PropertiesReturn> {
// TODO BLU-J set the properties ever so often
const setConfigValue = this.manifest.properties
if (!setConfigValue) throw new Error("There is no properties")
@@ -867,7 +945,8 @@ export class SystemForEmbassy implements System {
actionId: "config",
packageId: id,
replayId: `${id}/config`,
description: `Configure this dependency for the needs of ${this.manifest.title}`,
severity: "important",
reason: `Configure this dependency for the needs of ${this.manifest.title}`,
input: {
kind: "partial",
value: diff.diff,

View File

@@ -57,12 +57,6 @@ export class SystemForStartOs implements System {
effects,
}))
}
properties(
effects: Effects,
timeoutMs: number | null,
): Promise<T.PropertiesReturn> {
throw new Error("Method not implemented.")
}
getActionInput(
effects: Effects,
id: string,

View File

@@ -8,7 +8,6 @@ export type Procedure =
| "/packageUninit"
| "/backup/create"
| "/backup/restore"
| "/properties"
| `/actions/${string}/getInput`
| `/actions/${string}/run`
@@ -30,10 +29,6 @@ export type System = {
createBackup(effects: T.Effects, timeoutMs: number | null): Promise<void>
restoreBackup(effects: T.Effects, timeoutMs: number | null): Promise<void>
properties(
effects: Effects,
timeoutMs: number | null,
): Promise<T.PropertiesReturn>
runAction(
effects: Effects,
actionId: string,

View File

@@ -1,6 +1,6 @@
import { T } from "@start9labs/start-sdk"
const CallbackIdCell = { inc: 0 }
const CallbackIdCell = { inc: 1 }
const callbackRegistry = new FinalizationRegistry(
async (options: { cbs: Map<number, Function>; effects: T.Effects }) => {
@@ -23,6 +23,7 @@ export class CallbackHolder {
return
}
const id = this.newId()
console.error("adding callback", id)
this.callbacks.set(id, callback)
if (this.effects)
callbackRegistry.register(this, {

View File

@@ -23,8 +23,6 @@ export const jsonPath = some(
"/packageUninit",
"/backup/create",
"/backup/restore",
"/actions/metadata",
"/properties",
),
string.refine(isNestedPath, "isNestedPath"),
)

View File

@@ -7,7 +7,6 @@ pub enum ProcedureName {
GetConfig,
SetConfig,
CreateBackup,
Properties,
RestoreBackup,
GetActionInput(ActionId),
RunAction(ActionId),
@@ -23,7 +22,6 @@ impl ProcedureName {
ProcedureName::SetConfig => "/config/set".to_string(),
ProcedureName::GetConfig => "/config/get".to_string(),
ProcedureName::CreateBackup => "/backup/create".to_string(),
ProcedureName::Properties => "/properties".to_string(),
ProcedureName::RestoreBackup => "/backup/restore".to_string(),
ProcedureName::RunAction(id) => format!("/actions/{}/run", id),
ProcedureName::GetActionInput(id) => format!("/actions/{}/getInput", id),

View File

@@ -6,6 +6,7 @@ use models::PackageId;
use qrcode::QrCode;
use rpc_toolkit::{from_fn_async, Context, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use tracing::instrument;
use ts_rs::TS;
@@ -74,21 +75,25 @@ pub async fn get_action_input(
.await
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(tag = "version")]
#[ts(export)]
pub enum ActionResult {
#[serde(rename = "0")]
V0(ActionResultV0),
#[serde(rename = "1")]
V1(ActionResultV1),
}
impl fmt::Display for ActionResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::V0(res) => res.fmt(f),
Self::V1(res) => res.fmt(f),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, TS)]
pub struct ActionResultV0 {
pub message: String,
pub value: Option<String>,
@@ -116,6 +121,96 @@ impl fmt::Display for ActionResultV0 {
}
}
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all_fields = "camelCase")]
#[serde(tag = "type")]
pub enum ActionResultV1 {
String {
name: String,
value: String,
description: Option<String>,
copyable: bool,
qr: bool,
masked: bool,
},
Object {
name: String,
value: Vec<ActionResultV1>,
#[ts(optional)]
description: Option<String>,
},
}
impl ActionResultV1 {
fn fmt_rec(&self, f: &mut fmt::Formatter<'_>, indent: usize) -> fmt::Result {
match self {
Self::String {
name,
value,
description,
qr,
..
} => {
for i in 0..indent {
write!(f, " ")?;
}
write!(f, "{name}")?;
if let Some(description) = description {
write!(f, ": {description}")?;
}
if !value.is_empty() {
write!(f, ":\n")?;
for i in 0..indent {
write!(f, " ")?;
}
write!(f, "{value}")?;
if *qr {
use qrcode::render::unicode;
write!(f, "\n")?;
for i in 0..indent {
write!(f, " ")?;
}
write!(
f,
"{}",
QrCode::new(value.as_bytes())
.unwrap()
.render::<unicode::Dense1x2>()
.build()
)?;
}
}
}
Self::Object {
name,
value,
description,
} => {
for i in 0..indent {
write!(f, " ")?;
}
write!(f, "{name}")?;
if let Some(description) = description {
write!(f, ": {description}")?;
}
for value in value {
write!(f, ":\n")?;
for i in 0..indent {
write!(f, " ")?;
}
value.fmt_rec(f, indent + 1)?;
}
}
}
Ok(())
}
}
impl fmt::Display for ActionResultV1 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.fmt_rec(f, 0)
}
}
pub fn display_action_result<T: Serialize>(params: WithIoFormat<T>, result: Option<ActionResult>) {
let Some(result) = result else {
return;

View File

@@ -228,6 +228,8 @@ pub async fn subscribe(
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct CliApplyParams {
#[arg(long)]
allow_model_mismatch: bool,
expr: String,
path: Option<PathBuf>,
}
@@ -238,7 +240,12 @@ async fn cli_apply(
context,
parent_method,
method,
params: CliApplyParams { expr, path },
params:
CliApplyParams {
allow_model_mismatch,
expr,
path,
},
..
}: HandlerArgs<CliContext, CliApplyParams>,
) -> Result<(), RpcError> {
@@ -253,7 +260,14 @@ async fn cli_apply(
&expr,
)?;
Ok::<_, Error>((
let value = if allow_model_mismatch {
serde_json::from_value::<Value>(res.clone().into()).with_ctx(|_| {
(
crate::ErrorKind::Deserialization,
"result does not match database model",
)
})?
} else {
to_value(
&serde_json::from_value::<model::Database>(res.clone().into()).with_ctx(
|_| {
@@ -263,9 +277,9 @@ async fn cli_apply(
)
},
)?,
)?,
(),
))
)?
};
Ok::<_, Error>((value, ()))
})
.await?;
} else {

View File

@@ -338,7 +338,7 @@ pub struct ActionMetadata {
#[serde(rename_all_fields = "camelCase")]
pub enum ActionVisibility {
Hidden,
Disabled { reason: String },
Disabled(String),
Enabled,
}
impl Default for ActionVisibility {
@@ -444,14 +444,29 @@ pub struct ActionRequestEntry {
pub struct ActionRequest {
pub package_id: PackageId,
pub action_id: ActionId,
#[serde(default)]
pub severity: ActionSeverity,
#[ts(optional)]
pub description: Option<String>,
pub reason: Option<String>,
#[ts(optional)]
pub when: Option<ActionRequestTrigger>,
#[ts(optional)]
pub input: Option<ActionRequestInput>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export)]
pub enum ActionSeverity {
Critical,
Important,
}
impl Default for ActionSeverity {
fn default() -> Self {
ActionSeverity::Important
}
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]

View File

@@ -49,7 +49,6 @@ pub mod notifications;
pub mod os_install;
pub mod prelude;
pub mod progress;
pub mod properties;
pub mod registry;
pub mod rpc_continuations;
pub mod s9pk;
@@ -395,15 +394,6 @@ pub fn package<C: Context>() -> ParentHandler<C> {
.no_display()
.with_about("Display package logs"),
)
.subcommand(
"properties",
from_fn_async(properties::properties)
.with_custom_display_fn(|_handle, result| {
Ok(properties::display_properties(result))
})
.with_about("Display package Properties")
.with_call_remote::<CliContext>(),
)
.subcommand(
"backup",
backup::package_backup::<C>()

View File

@@ -113,7 +113,7 @@ async fn ws_handler(
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LogResponse {
entries: Reversible<LogEntry>,
pub entries: Reversible<LogEntry>,
start_cursor: Option<String>,
end_cursor: Option<String>,
}

View File

@@ -1,35 +0,0 @@
use clap::Parser;
use imbl_value::{json, Value};
use models::PackageId;
use serde::{Deserialize, Serialize};
use crate::context::RpcContext;
use crate::prelude::*;
use crate::Error;
pub fn display_properties(response: Value) {
println!("{}", response);
}
#[derive(Deserialize, Serialize, Parser)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct PropertiesParam {
id: PackageId,
}
// #[command(display(display_properties))]
pub async fn properties(
ctx: RpcContext,
PropertiesParam { id }: PropertiesParam,
) -> Result<Value, Error> {
match &*ctx.services.get(&id).await {
Some(service) => Ok(json!({
"version": 2,
"data": service.properties().await?
})),
None => Err(Error::new(
eyre!("Could not find a service with id {id}"),
ErrorKind::NotFound,
)),
}
}

View File

@@ -32,6 +32,7 @@ pub async fn bind(
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ClearBindingsParams {
#[serde(default)]
pub except: Vec<BindId>,
}

View File

@@ -55,7 +55,6 @@ pub mod cli;
mod control;
pub mod effects;
pub mod persistent_container;
mod properties;
mod rpc;
mod service_actor;
pub mod service_map;
@@ -132,6 +131,7 @@ impl ServiceRef {
);
Ok(())
})?;
d.as_private_mut().as_package_stores_mut().remove(&id)?;
Ok(Some(pde))
} else {
Ok(None)

View File

@@ -1,4 +1,5 @@
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Deref;
use std::path::Path;
use std::sync::{Arc, Weak};
use std::time::Duration;
@@ -379,11 +380,7 @@ impl PersistentContainer {
));
}
self.rpc_client
.request(rpc::Init, Empty {})
.await
.map_err(Error::from)
.log_err();
self.rpc_client.request(rpc::Init, Empty {}).await?;
self.state.send_modify(|s| s.rt_initialized = true);
@@ -391,7 +388,10 @@ impl PersistentContainer {
}
#[instrument(skip_all)]
fn destroy(&mut self) -> Option<impl Future<Output = Result<(), Error>> + 'static> {
fn destroy(
&mut self,
error: bool,
) -> Option<impl Future<Output = Result<(), Error>> + 'static> {
if self.destroyed {
return None;
}
@@ -406,6 +406,24 @@ impl PersistentContainer {
self.destroyed = true;
Some(async move {
let mut errs = ErrorCollection::new();
if error {
if let Some(lxc_container) = &lxc_container {
if let Some(logs) = errs.handle(
crate::logs::fetch_logs(
crate::logs::LogSource::Container(lxc_container.guid.deref().clone()),
Some(50),
None,
None,
false,
)
.await,
) {
for log in logs.entries.iter() {
eprintln!("{log}");
}
}
}
}
if let Some((hdl, shutdown)) = rpc_server {
errs.handle(rpc_client.request(rpc::Exit, Empty {}).await);
shutdown.shutdown();
@@ -433,7 +451,7 @@ impl PersistentContainer {
#[instrument(skip_all)]
pub async fn exit(mut self) -> Result<(), Error> {
if let Some(destroy) = self.destroy() {
if let Some(destroy) = self.destroy(false) {
dbg!(destroy.await)?;
}
tracing::info!("Service for {} exited", self.s9pk.as_manifest().id);
@@ -551,7 +569,7 @@ impl PersistentContainer {
impl Drop for PersistentContainer {
fn drop(&mut self) {
if let Some(destroy) = self.destroy() {
if let Some(destroy) = self.destroy(true) {
tokio::spawn(async move { destroy.await.log_err() });
}
}

View File

@@ -1,23 +0,0 @@
use std::time::Duration;
use models::ProcedureName;
use crate::prelude::*;
use crate::rpc_continuations::Guid;
use crate::service::Service;
impl Service {
// TODO: leave here or switch to Actor Message?
pub async fn properties(&self) -> Result<Value, Error> {
let container = &self.seed.persistent_container;
container
.execute::<Value>(
Guid::new(),
ProcedureName::Properties,
Value::Null,
Some(Duration::from_secs(30)),
)
.await
.with_kind(ErrorKind::Unknown)
}
}

View File

@@ -1,3 +1,5 @@
use std::io;
use tracing::Subscriber;
use tracing_subscriber::util::SubscriberInitExt;
@@ -21,7 +23,11 @@ impl EmbassyLogger {
let filter_layer = filter_layer
.add_directive("tokio=trace".parse().unwrap())
.add_directive("runtime=trace".parse().unwrap());
let fmt_layer = fmt::layer().with_target(true);
let fmt_layer = fmt::layer()
.with_writer(io::stderr)
.with_line_number(true)
.with_file(true)
.with_target(true);
let sub = tracing_subscriber::registry()
.with(filter_layer)

View File

@@ -171,9 +171,17 @@ fn version_accessor(db: &mut Value) -> Option<&mut Value> {
fn version_compat_accessor(db: &mut Value) -> Option<&mut Value> {
if db.get("public").is_some() {
db.get_mut("public")?
.get_mut("serverInfo")?
.get_mut("versionCompat")
let server_info = db.get_mut("public")?.get_mut("serverInfo")?;
if server_info.get("versionCompat").is_some() {
server_info.get_mut("versionCompat")
} else {
if let Some(prev) = server_info.get("eosVersionCompat").cloned() {
server_info
.as_object_mut()?
.insert("versionCompat".into(), prev);
}
server_info.get_mut("versionCompat")
}
} else {
db.get_mut("server-info")?.get_mut("eos-version-compat")
}

View File

@@ -24,7 +24,7 @@ clean:
package/lib/test/output.ts: package/node_modules package/lib/test/makeOutput.ts package/scripts/oldSpecToBuilder.ts
cd package && npm run buildOutput
bundle: dist baseDist | test fmt
bundle: baseDist dist | test fmt
touch dist
base/lib/exver/exver.ts: base/node_modules base/lib/exver/exver.pegjs
@@ -67,9 +67,8 @@ base/node_modules: base/package.json
node_modules: package/node_modules base/node_modules
publish: bundle package/package.json README.md LICENSE
cd dist
npm publish --access=public
publish: bundle package/package.json package/README.md package/LICENSE
cd dist && npm publish --access=public
link: bundle
cd dist && npm link

View File

@@ -52,7 +52,7 @@ export type Effects = {
options: RequestActionParams,
): Promise<null>
clearRequests(
options: { only: ActionId[] } | { except: ActionId[] },
options: { only: string[] } | { except: string[] },
): Promise<null>
}

View File

@@ -1,5 +1,6 @@
import * as T from "../types"
import * as IST from "../actions/input/inputSpecTypes"
import { Action } from "./setupActions"
export type RunActionInput<Input> =
| Input
@@ -43,23 +44,62 @@ export const runAction = async <
})
}
}
type GetActionInputType<
A extends Action<T.ActionId, any, any, Record<string, unknown>>,
> = A extends Action<T.ActionId, any, any, infer I> ? I : never
// prettier-ignore
export type ActionRequest<T extends Omit<T.ActionRequest, "packageId">> =
T extends { when: { condition: "input-not-matches" } }
? (T extends { input: T.ActionRequestInput } ? T : "input is required for condition 'input-not-matches'")
: T
type ActionRequestBase = {
reason?: string
replayId?: string
}
type ActionRequestInput<
T extends Action<T.ActionId, any, any, Record<string, unknown>>,
> = {
kind: "partial"
value: Partial<GetActionInputType<T>>
}
export type ActionRequestOptions<
T extends Action<T.ActionId, any, any, Record<string, unknown>>,
> = ActionRequestBase &
(
| {
when?: Exclude<
T.ActionRequestTrigger,
{ condition: "input-not-matches" }
>
input?: ActionRequestInput<T>
}
| {
when: T.ActionRequestTrigger & { condition: "input-not-matches" }
input: ActionRequestInput<T>
}
)
const _validate: T.ActionRequest = {} as ActionRequestOptions<any> & {
actionId: string
packageId: string
severity: T.ActionSeverity
}
export const requestAction = <
T extends Omit<T.ActionRequest, "packageId">,
T extends Action<T.ActionId, any, any, Record<string, unknown>>,
>(options: {
effects: T.Effects
request: ActionRequest<T> & { replayId?: string; packageId: T.PackageId }
packageId: T.PackageId
action: T
severity: T.ActionSeverity
options?: ActionRequestOptions<T>
}) => {
const request = options.request
const request = options.options || {}
const actionId = options.action.id
const req = {
...request,
replayId: request.replayId || `${request.packageId}:${request.actionId}`,
actionId,
packageId: options.packageId,
action: undefined,
severity: options.severity,
replayId: request.replayId || `${options.packageId}:${actionId}`,
}
delete req.action
return options.effects.action.request(req)
}

View File

@@ -11,7 +11,7 @@ export type Run<
> = (options: {
effects: T.Effects
input: ExtractInputSpecType<A> & Record<string, any>
}) => Promise<T.ActionResult | null>
}) => Promise<T.ActionResult | null | void | undefined>
export type GetInput<
A extends
| Record<string, any>
@@ -19,7 +19,9 @@ export type GetInput<
| InputSpec<Record<string, any>, never>,
> = (options: {
effects: T.Effects
}) => Promise<null | (ExtractInputSpecType<A> & Record<string, any>)>
}) => Promise<
null | void | undefined | (ExtractInputSpecType<A> & Record<string, any>)
>
export type MaybeFn<T> = T | ((options: { effects: T.Effects }) => Promise<T>)
function callMaybeFn<T>(
@@ -91,7 +93,7 @@ export class Action<
): Action<Id, Store, {}, {}> {
return new Action(
id,
mapMaybeFn(metadata, (m) => ({ ...m, hasInput: true })),
mapMaybeFn(metadata, (m) => ({ ...m, hasInput: false })),
{},
async () => null,
run,
@@ -114,7 +116,7 @@ export class Action<
effects: T.Effects
input: Type
}): Promise<T.ActionResult | null> {
return this.runFn(options)
return (await this.runFn(options)) || null
}
}

View File

@@ -1,21 +0,0 @@
import { VersionRange } from "../exver"
export class Dependency {
constructor(
readonly data:
| {
/** Either "running" or "exists". Does the dependency need to be running, or does it only need to exist? */
type: "running"
/** The acceptable version range of the dependency. */
versionRange: VersionRange
/** A list of the dependency's health check IDs that must be passing for the service to be satisfied. */
healthChecks: string[]
}
| {
/** Either "running" or "exists". Does the dependency need to be running, or does it only need to exist? */
type: "exists"
/** The acceptable version range of the dependency. */
versionRange: VersionRange
},
) {}
}

View File

@@ -1,22 +1,11 @@
import * as T from "../types"
import { once } from "../util"
import { Dependency } from "./Dependency"
type DependencyType<Manifest extends T.Manifest> = {
[K in keyof {
[K in keyof Manifest["dependencies"]]: Manifest["dependencies"][K]["optional"] extends false
? K
: never
}]: Dependency
} & {
[K in keyof {
[K in keyof Manifest["dependencies"]]: Manifest["dependencies"][K]["optional"] extends true
? K
: never
}]?: Dependency
type DependencyType<Manifest extends T.SDKManifest> = {
[K in keyof Manifest["dependencies"]]: Omit<T.DependencyRequirement, "id">
}
export function setupDependencies<Manifest extends T.Manifest>(
export function setupDependencies<Manifest extends T.SDKManifest>(
fn: (options: { effects: T.Effects }) => Promise<DependencyType<Manifest>>,
): (options: { effects: T.Effects }) => Promise<null> {
const cell = { updater: async (_: { effects: T.Effects }) => null }
@@ -30,24 +19,12 @@ export function setupDependencies<Manifest extends T.Manifest>(
const dependencyType = await fn(options)
return await options.effects.setDependencies({
dependencies: Object.entries(dependencyType).map(
([
id,
{
data: { versionRange, ...x },
},
]) => ({
id,
...x,
...(x.type === "running"
? {
kind: "running",
healthChecks: x.healthChecks,
}
: {
kind: "exists",
}),
versionRange: versionRange.toString(),
}),
([id, { versionRange, ...x }, ,]) =>
({
id,
...x,
versionRange: versionRange.toString(),
}) as T.DependencyRequirement,
),
})
}

View File

@@ -2,12 +2,14 @@
import type { ActionId } from "./ActionId"
import type { ActionRequestInput } from "./ActionRequestInput"
import type { ActionRequestTrigger } from "./ActionRequestTrigger"
import type { ActionSeverity } from "./ActionSeverity"
import type { PackageId } from "./PackageId"
export type ActionRequest = {
packageId: PackageId
actionId: ActionId
description?: string
severity: ActionSeverity
reason?: string
when?: ActionRequestTrigger
input?: ActionRequestInput
}

View File

@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ActionResultV0 } from "./ActionResultV0"
import type { ActionResultV1 } from "./ActionResultV1"
export type ActionResult =
| ({ version: "0" } & ActionResultV0)
| ({ version: "1" } & ActionResultV1)

View File

@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ActionResultV0 = {
message: string
value: string | null
copyable: boolean
qr: boolean
}

View File

@@ -0,0 +1,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ActionResultV1 =
| {
type: "string"
name: string
value: string
description: string | null
copyable: boolean
qr: boolean
masked: boolean
}
| {
type: "object"
name: string
value: Array<ActionResultV1>
description?: string
}

View File

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

View File

@@ -1,6 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ActionVisibility =
| "hidden"
| { disabled: { reason: string } }
| "enabled"
export type ActionVisibility = "hidden" | { disabled: string } | "enabled"

View File

@@ -2,6 +2,7 @@
import type { ActionId } from "./ActionId"
import type { ActionRequestInput } from "./ActionRequestInput"
import type { ActionRequestTrigger } from "./ActionRequestTrigger"
import type { ActionSeverity } from "./ActionSeverity"
import type { PackageId } from "./PackageId"
import type { ReplayId } from "./ReplayId"
@@ -9,7 +10,8 @@ export type RequestActionParams = {
replayId: ReplayId
packageId: PackageId
actionId: ActionId
description?: string
severity: ActionSeverity
reason?: string
when?: ActionRequestTrigger
input?: ActionRequestInput
}

View File

@@ -7,6 +7,10 @@ export { ActionRequestEntry } from "./ActionRequestEntry"
export { ActionRequestInput } from "./ActionRequestInput"
export { ActionRequestTrigger } from "./ActionRequestTrigger"
export { ActionRequest } from "./ActionRequest"
export { ActionResult } from "./ActionResult"
export { ActionResultV0 } from "./ActionResultV0"
export { ActionResultV1 } from "./ActionResultV1"
export { ActionSeverity } from "./ActionSeverity"
export { ActionVisibility } from "./ActionVisibility"
export { AddAdminParams } from "./AddAdminParams"
export { AddAssetParams } from "./AddAssetParams"

View File

@@ -33,10 +33,6 @@ export const SIGKILL: Signals = "SIGKILL"
export const NO_TIMEOUT = -1
export type PathMaker = (options: { volume: string; path: string }) => string
export type ExportedAction = (options: {
effects: Effects
input?: Record<string, unknown>
}) => Promise<ActionResult>
export type MaybePromise<A> = Promise<A> | A
export namespace ExpectedExports {
version: 1
@@ -86,10 +82,6 @@ export namespace ExpectedExports {
nextVersion: null | string
}) => Promise<unknown>
export type properties = (options: {
effects: Effects
}) => Promise<PropertiesReturn>
export type manifest = Manifest
export type actions = Actions<
@@ -105,7 +97,6 @@ export type ABI = {
containerInit: ExpectedExports.containerInit
packageInit: ExpectedExports.packageInit
packageUninit: ExpectedExports.packageUninit
properties: ExpectedExports.properties
manifest: ExpectedExports.manifest
actions: ExpectedExports.actions
}
@@ -177,58 +168,6 @@ export type ExposeServicePaths<Store = never> = {
paths: ExposedStorePaths
}
export type SdkPropertiesValue =
| {
type: "object"
value: { [k: string]: SdkPropertiesValue }
description?: string
}
| {
type: "string"
/** The value to display to the user */
value: string
/** A human readable description or explanation of the value */
description?: string
/** Whether or not to mask the value, for example, when displaying a password */
masked?: boolean
/** Whether or not to include a button for copying the value to clipboard */
copyable?: boolean
/** Whether or not to include a button for displaying the value as a QR code */
qr?: boolean
}
export type SdkPropertiesReturn = {
[key: string]: SdkPropertiesValue
}
export type PropertiesValue =
| {
/** The type of this value, either "string" or "object" */
type: "object"
/** A nested mapping of values. The user will experience this as a nested page with back button */
value: { [k: string]: PropertiesValue }
/** (optional) A human readable description of the new set of values */
description: string | null
}
| {
/** The type of this value, either "string" or "object" */
type: "string"
/** The value to display to the user */
value: string
/** A human readable description of the value */
description: string | null
/** Whether or not to mask the value, for example, when displaying a password */
masked: boolean | null
/** Whether or not to include a button for copying the value to clipboard */
copyable: boolean | null
/** Whether or not to include a button for displaying the value as a QR code */
qr: boolean | null
}
export type PropertiesReturn = {
[key: string]: PropertiesValue
}
export type EffectMethod<T extends StringObject = Effects> = {
[K in keyof T]-?: K extends string
? T[K] extends Function
@@ -264,13 +203,6 @@ export type Metadata = {
mode: number
}
export type ActionResult = {
version: "0"
message: string
value: string | null
copyable: boolean
qr: boolean
}
export type SetResult = {
dependsOn: DependsOn
signal: Signals

View File

@@ -4,9 +4,9 @@
"types": "./index.d.ts",
"sideEffects": true,
"scripts": {
"peggy": "peggy --allowed-start-rules '*' --plugin ./node_modules/ts-pegjs/dist/tspegjs -o lib/exver/exver.ts lib/exver/exver.pegjs",
"peggy": "peggy --allowed-start-rules \"*\" --plugin ./node_modules/ts-pegjs/dist/tspegjs -o lib/exver/exver.ts lib/exver/exver.pegjs",
"test": "jest -c ./jest.config.js --coverage",
"buildOutput": "npx prettier --write '**/*.ts'",
"buildOutput": "npx prettier --write \"**/*.ts\"",
"check": "tsc --noEmit",
"tsc": "tsc"
},

View File

@@ -55,7 +55,6 @@ import { getStore } from "./store/getStore"
import { CommandOptions, MountOptions, SubContainer } from "./util/SubContainer"
import { splitCommand } from "./util"
import { Mounts } from "./mainFn/Mounts"
import { Dependency } from "../../base/lib/dependencies/Dependency"
import { setupDependencies } from "../../base/lib/dependencies/setupDependencies"
import * as T from "../../base/lib/types"
import { testTypeVersion } from "../../base/lib/exver"
@@ -86,12 +85,12 @@ type AnyNeverCond<T extends any[], Then, Else> =
T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> :
never
export class StartSdk<Manifest extends T.Manifest, Store> {
export class StartSdk<Manifest extends T.SDKManifest, Store> {
private constructor(readonly manifest: Manifest) {}
static of() {
return new StartSdk<never, never>(null as never)
}
withManifest<Manifest extends T.Manifest = never>(manifest: Manifest) {
withManifest<Manifest extends T.SDKManifest = never>(manifest: Manifest) {
return new StartSdk<Manifest, Store>(manifest)
}
withStore<Store extends Record<string, any>>() {
@@ -141,17 +140,39 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
...startSdkEffectWrapper,
action: {
run: actions.runAction,
request: actions.requestAction,
requestOwn: <T extends Omit<T.ActionRequest, "packageId">>(
request: <
T extends Action<T.ActionId, any, any, Record<string, unknown>>,
>(
effects: T.Effects,
request: actions.ActionRequest<T> & {
replayId?: string
},
packageId: T.PackageId,
action: T,
severity: T.ActionSeverity,
options?: actions.ActionRequestOptions<T>,
) =>
actions.requestAction({
effects,
request: { ...request, packageId: this.manifest.id },
packageId,
action,
severity,
options: options,
}),
requestOwn: <
T extends Action<T.ActionId, Store, any, Record<string, unknown>>,
>(
effects: T.Effects,
action: T,
severity: T.ActionSeverity,
options?: actions.ActionRequestOptions<T>,
) =>
actions.requestAction({
effects,
packageId: this.manifest.id,
action,
severity,
options: options,
}),
clearRequest: (effects: T.Effects, ...replayIds: string[]) =>
effects.action.clearRequests({ only: replayIds }),
},
checkDependencies: checkDependencies as <
DependencyId extends keyof Manifest["dependencies"] &
@@ -370,17 +391,6 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
return healthCheck(o)
},
},
Dependency: {
/**
* @description Use this function to create a dependency for the service.
* @property {DependencyType} type
* @property {VersionRange} versionRange
* @property {string[]} healthChecks
*/
of(data: Dependency["data"]) {
return new Dependency({ ...data })
},
},
healthCheck: {
checkPortListening,
checkWebUrl,
@@ -566,37 +576,6 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
started(onTerm: () => PromiseLike<void>): PromiseLike<null>
}) => Promise<Daemons<Manifest, any>>,
) => setupMain<Manifest, Store>(fn),
/**
* @description Use this function to determine which information to expose to the UI in the "Properties" section.
*
* Values can be obtained from anywhere: the Store, the upstream service, or another service.
* @example
* In this example, we retrieve the admin password from the Store and expose it, masked and copyable, to
* the UI as "Admin Password".
*
* ```
export const properties = sdk.setupProperties(async ({ effects }) => {
const store = await sdk.store.getOwn(effects, sdk.StorePath).once()
return {
'Admin Password': {
type: 'string',
value: store.adminPassword,
description: 'Used for logging into the admin UI',
copyable: true,
masked: true,
qr: false,
},
}
})
* ```
*/
setupProperties:
(
fn: (options: { effects: Effects }) => Promise<T.SdkPropertiesReturn>,
): T.ExpectedExports.properties =>
(options) =>
fn(options).then(nullifyProperties),
/**
* Use this function to execute arbitrary logic *once*, on uninstall only. Most services will not use this.
*/
@@ -1057,6 +1036,7 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
* ```
*/
list: Value.list,
hidden: Value.hidden,
dynamicToggle: (
a: LazyBuild<
Store,
@@ -1367,7 +1347,7 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
}
}
export async function runCommand<Manifest extends T.Manifest>(
export async function runCommand<Manifest extends T.SDKManifest>(
effects: Effects,
image: { id: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },
command: string | [string, ...string[]],
@@ -1385,26 +1365,3 @@ export async function runCommand<Manifest extends T.Manifest>(
(subcontainer) => subcontainer.exec(commands),
)
}
function nullifyProperties(value: T.SdkPropertiesReturn): T.PropertiesReturn {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, nullifyProperties_(v)]),
)
}
function nullifyProperties_(value: T.SdkPropertiesValue): T.PropertiesValue {
if (value.type === "string") {
return {
description: null,
copyable: null,
masked: null,
qr: null,
...value,
}
}
return {
description: null,
...value,
value: Object.fromEntries(
Object.entries(value.value).map(([k, v]) => [k, nullifyProperties_(v)]),
),
}
}

View File

@@ -35,7 +35,7 @@ export type BackupSync<Volumes extends string> = {
* ).build()q
* ```
*/
export class Backups<M extends T.Manifest> {
export class Backups<M extends T.SDKManifest> {
private constructor(
private options = DEFAULT_OPTIONS,
private restoreOptions: Partial<T.SyncOptions> = {},
@@ -43,7 +43,7 @@ export class Backups<M extends T.Manifest> {
private backupSet = [] as BackupSync<M["volumes"][number]>[],
) {}
static withVolumes<M extends T.Manifest = never>(
static withVolumes<M extends T.SDKManifest = never>(
...volumeNames: Array<M["volumes"][number]>
): Backups<M> {
return Backups.withSyncs(
@@ -54,13 +54,13 @@ export class Backups<M extends T.Manifest> {
)
}
static withSyncs<M extends T.Manifest = never>(
static withSyncs<M extends T.SDKManifest = never>(
...syncs: BackupSync<M["volumes"][number]>[]
) {
return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>())
}
static withOptions<M extends T.Manifest = never>(
static withOptions<M extends T.SDKManifest = never>(
options?: Partial<T.SyncOptions>,
) {
return new Backups<M>({ ...DEFAULT_OPTIONS, ...options })

View File

@@ -2,7 +2,7 @@ import { Backups } from "./Backups"
import * as T from "../../../base/lib/types"
import { _ } from "../util"
export type SetupBackupsParams<M extends T.Manifest> =
export type SetupBackupsParams<M extends T.SDKManifest> =
| M["volumes"][number][]
| ((_: { effects: T.Effects }) => Promise<Backups<M>>)
@@ -11,7 +11,7 @@ type SetupBackupsRes = {
restoreBackup: T.ExpectedExports.restoreBackup
}
export function setupBackups<M extends T.Manifest>(
export function setupBackups<M extends T.SDKManifest>(
options: SetupBackupsParams<M>,
) {
let backupsFactory: (_: { effects: T.Effects }) => Promise<Backups<M>>

View File

@@ -28,7 +28,7 @@ export {
export { Daemons } from "./mainFn/Daemons"
export { SubContainer } from "./util/SubContainer"
export { StartSdk } from "./StartSdk"
export { setupManifest } from "./manifest/setupManifest"
export { setupManifest, buildManifest } from "./manifest/setupManifest"
export { FileHelper } from "./util/fileHelper"
export { setupExposeStore } from "./store/setupExposeStore"
export { pathBuilder } from "../../base/lib/util/PathBuilder"

View File

@@ -7,12 +7,14 @@ import { VersionGraph } from "../version/VersionGraph"
import { Install } from "./setupInstall"
import { Uninstall } from "./setupUninstall"
export function setupInit<Manifest extends T.Manifest, Store>(
versions: VersionGraph<Manifest["version"]>,
export function setupInit<Manifest extends T.SDKManifest, Store>(
versions: VersionGraph<string>,
install: Install<Manifest, Store>,
uninstall: Uninstall<Manifest, Store>,
setServiceInterfaces: UpdateServiceInterfaces<any>,
setDependencies: (options: { effects: T.Effects }) => Promise<null>,
setDependencies: (options: {
effects: T.Effects
}) => Promise<null | void | undefined>,
actions: Actions<Store, any>,
exposedStore: ExposedStorePaths,
): {

View File

@@ -1,11 +1,11 @@
import * as T from "../../../base/lib/types"
export type InstallFn<Manifest extends T.Manifest, Store> = (opts: {
export type InstallFn<Manifest extends T.SDKManifest, Store> = (opts: {
effects: T.Effects
}) => Promise<null>
export class Install<Manifest extends T.Manifest, Store> {
}) => Promise<null | void | undefined>
export class Install<Manifest extends T.SDKManifest, Store> {
private constructor(readonly fn: InstallFn<Manifest, Store>) {}
static of<Manifest extends T.Manifest, Store>(
static of<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
) {
return new Install(fn)
@@ -18,7 +18,7 @@ export class Install<Manifest extends T.Manifest, Store> {
}
}
export function setupInstall<Manifest extends T.Manifest, Store>(
export function setupInstall<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
) {
return Install.of(fn)

View File

@@ -1,11 +1,11 @@
import * as T from "../../../base/lib/types"
export type UninstallFn<Manifest extends T.Manifest, Store> = (opts: {
export type UninstallFn<Manifest extends T.SDKManifest, Store> = (opts: {
effects: T.Effects
}) => Promise<null>
export class Uninstall<Manifest extends T.Manifest, Store> {
}) => Promise<null | void | undefined>
export class Uninstall<Manifest extends T.SDKManifest, Store> {
private constructor(readonly fn: UninstallFn<Manifest, Store>) {}
static of<Manifest extends T.Manifest, Store>(
static of<Manifest extends T.SDKManifest, Store>(
fn: UninstallFn<Manifest, Store>,
) {
return new Uninstall(fn)
@@ -22,7 +22,7 @@ export class Uninstall<Manifest extends T.Manifest, Store> {
}
}
export function setupUninstall<Manifest extends T.Manifest, Store>(
export function setupUninstall<Manifest extends T.SDKManifest, Store>(
fn: UninstallFn<Manifest, Store>,
) {
return Uninstall.of(fn)

View File

@@ -20,7 +20,7 @@ export class CommandController {
private process: cp.ChildProcessWithoutNullStreams,
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
) {}
static of<Manifest extends T.Manifest>() {
static of<Manifest extends T.SDKManifest>() {
return async <A extends string>(
effects: T.Effects,
subcontainer:

View File

@@ -17,7 +17,7 @@ export class Daemon {
get subContainerHandle(): undefined | ExecSpawnable {
return this.commandController?.subContainerHandle
}
static of<Manifest extends T.Manifest>() {
static of<Manifest extends T.SDKManifest>() {
return async <A extends string>(
effects: T.Effects,
subcontainer:

View File

@@ -27,7 +27,7 @@ export type Ready = {
}
type DaemonsParams<
Manifest extends T.Manifest,
Manifest extends T.SDKManifest,
Ids extends string,
Command extends string,
Id extends string,
@@ -43,7 +43,7 @@ type DaemonsParams<
type ErrorDuplicateId<Id extends string> = `The id '${Id}' is already used`
export const runCommand = <Manifest extends T.Manifest>() =>
export const runCommand = <Manifest extends T.SDKManifest>() =>
CommandController.of<Manifest>()
/**
@@ -69,7 +69,7 @@ Daemons.of({
})
```
*/
export class Daemons<Manifest extends T.Manifest, Ids extends string>
export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
implements T.DaemonBuildable
{
private constructor(
@@ -89,7 +89,7 @@ export class Daemons<Manifest extends T.Manifest, Ids extends string>
* @param options
* @returns
*/
static of<Manifest extends T.Manifest>(options: {
static of<Manifest extends T.SDKManifest>(options: {
effects: T.Effects
started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>
healthReceipts: HealthReceipt[]

View File

@@ -3,7 +3,7 @@ import { MountOptions } from "../util/SubContainer"
type MountArray = { path: string; options: MountOptions }[]
export class Mounts<Manifest extends T.Manifest> {
export class Mounts<Manifest extends T.SDKManifest> {
private constructor(
readonly volumes: {
id: Manifest["volumes"][number]
@@ -25,7 +25,7 @@ export class Mounts<Manifest extends T.Manifest> {
}[],
) {}
static of<Manifest extends T.Manifest>() {
static of<Manifest extends T.SDKManifest>() {
return new Mounts<Manifest>([], [], [])
}
@@ -57,7 +57,7 @@ export class Mounts<Manifest extends T.Manifest> {
return this
}
addDependency<DependencyManifest extends T.Manifest>(
addDependency<DependencyManifest extends T.SDKManifest>(
dependencyId: keyof Manifest["dependencies"] & string,
volumeId: DependencyManifest["volumes"][number],
subpath: string | null,

View File

@@ -14,7 +14,7 @@ export const DEFAULT_SIGTERM_TIMEOUT = 30_000
* @param fn
* @returns
*/
export const setupMain = <Manifest extends T.Manifest, Store>(
export const setupMain = <Manifest extends T.SDKManifest, Store>(
fn: (o: {
effects: T.Effects
started(onTerm: () => PromiseLike<void>): PromiseLike<null>

View File

@@ -14,6 +14,23 @@ import { VersionGraph } from "../version/VersionGraph"
* @param manifest Static properties of the package
*/
export function setupManifest<
Id extends string,
Dependencies extends Record<string, unknown>,
VolumesTypes extends VolumeId,
AssetTypes extends VolumeId,
ImagesTypes extends ImageId,
Manifest extends {
dependencies: Dependencies
id: Id
assets: AssetTypes[]
images: Record<ImagesTypes, SDKImageInputSpec>
volumes: VolumesTypes[]
},
>(manifest: SDKManifest & Manifest): SDKManifest & Manifest {
return manifest
}
export function buildManifest<
Id extends string,
Version extends string,
Dependencies extends Record<string, unknown>,
@@ -27,7 +44,6 @@ export function setupManifest<
images: Record<ImagesTypes, SDKImageInputSpec>
volumes: VolumesTypes[]
},
Satisfies extends string[] = [],
>(
versions: VersionGraph<Version>,
manifest: SDKManifest & Manifest,

View File

@@ -367,48 +367,39 @@ describe("values", () => {
test("datetime", async () => {
const sdk = StartSdk.of()
.withManifest(
setupManifest(
VersionGraph.of(
VersionInfo.of({
version: "1.0.0:0",
releaseNotes: "",
migrations: {},
}),
),
{
id: "testOutput",
title: "",
license: "",
wrapperRepo: "",
upstreamRepo: "",
supportSite: "",
marketingSite: "",
donationUrl: null,
description: {
short: "",
long: "",
},
containers: {},
images: {},
volumes: [],
assets: [],
alerts: {
install: null,
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {
"remote-test": {
description: "",
optional: true,
s9pk: "https://example.com/remote-test.s9pk",
},
setupManifest({
id: "testOutput",
title: "",
license: "",
wrapperRepo: "",
upstreamRepo: "",
supportSite: "",
marketingSite: "",
donationUrl: null,
description: {
short: "",
long: "",
},
containers: {},
images: {},
volumes: [],
assets: [],
alerts: {
install: null,
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {
"remote-test": {
description: "",
optional: true,
s9pk: "https://example.com/remote-test.s9pk",
},
},
),
}),
)
.withStore<{ test: "a" }>()
.build(true)

View File

@@ -6,51 +6,40 @@ import { VersionGraph } from "../version/VersionGraph"
export type Manifest = any
export const sdk = StartSdk.of()
.withManifest(
setupManifest(
VersionGraph.of(
VersionInfo.of({
version: "1.0.0:0",
releaseNotes: "",
migrations: {},
})
.satisfies("#other:1.0.0:0")
.satisfies("#other:2.0.0:0"),
),
{
id: "testOutput",
title: "",
license: "",
replaces: [],
wrapperRepo: "",
upstreamRepo: "",
supportSite: "",
marketingSite: "",
donationUrl: null,
description: {
short: "",
long: "",
},
containers: {},
images: {},
volumes: [],
assets: [],
alerts: {
install: null,
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {
"remote-test": {
description: "",
optional: false,
s9pk: "https://example.com/remote-test.s9pk",
},
setupManifest({
id: "testOutput",
title: "",
license: "",
replaces: [],
wrapperRepo: "",
upstreamRepo: "",
supportSite: "",
marketingSite: "",
donationUrl: null,
description: {
short: "",
long: "",
},
containers: {},
images: {},
volumes: [],
assets: [],
alerts: {
install: null,
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {
"remote-test": {
description: "",
optional: false,
s9pk: "https://example.com/remote-test.s9pk",
},
},
),
}),
)
.withStore<{ storeRoot: { storeLeaf: "value" } }>()
.build(true)

View File

@@ -86,19 +86,21 @@ export class FileHelper<A> {
/**
* Accepts structured data and overwrites the existing file on disk.
*/
async write(data: A) {
async write(data: A): Promise<null> {
const parent = previousPath.exec(this.path)
if (parent) {
await fs.mkdir(parent[1], { recursive: true })
}
await fs.writeFile(this.path, this.writeData(data))
return null
}
/**
* Reads the file from disk and converts it to structured data.
*/
async read() {
private async readOnce(): Promise<A | null> {
if (!(await exists(this.path))) {
return null
}
@@ -107,14 +109,14 @@ export class FileHelper<A> {
)
}
async const(effects: T.Effects) {
const watch = this.watch()
private async readConst(effects: T.Effects): Promise<A | null> {
const watch = this.readWatch()
const res = await watch.next()
watch.next().then(effects.constRetry)
return res.value
}
async *watch() {
private async *readWatch() {
let res
while (true) {
if (await exists(this.path)) {
@@ -123,12 +125,12 @@ export class FileHelper<A> {
persistent: false,
signal: ctrl.signal,
})
res = await this.read()
res = await this.readOnce()
const listen = Promise.resolve()
.then(async () => {
for await (const _ of watch) {
ctrl.abort("finished")
return
return null
}
})
.catch((e) => console.error(asError(e)))
@@ -139,13 +141,22 @@ export class FileHelper<A> {
await onCreated(this.path).catch((e) => console.error(asError(e)))
}
}
return null
}
get read() {
return {
once: () => this.readOnce(),
const: (effects: T.Effects) => this.readConst(effects),
watch: () => this.readWatch(),
}
}
/**
* Accepts structured data and performs a merge with the existing file on disk.
*/
async merge(data: A) {
const fileData = (await this.read().catch(() => ({}))) || {}
const fileData = (await this.readOnce().catch(() => ({}))) || {}
const mergeData = merge({}, fileData, data)
return await this.write(mergeData)
}

View File

@@ -1,12 +1,12 @@
{
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha8",
"version": "0.3.6-alpha9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha8",
"version": "0.3.6-alpha9",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
@@ -14,7 +14,7 @@
"@noble/hashes": "^1.4.0",
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"mime": "^4.0.3",
"mime-types": "^2.1.35",
"ts-matches": "^5.5.1",
"yaml": "^2.2.2"
},
@@ -3136,18 +3136,25 @@
"node": ">=8.6"
}
},
"node_modules/mime": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.3.tgz",
"integrity": "sha512-KgUb15Oorc0NEKPbvfa0wRU+PItIEZmiv+pyAO2i0oTIVTJhlzMclU7w4RXWQrSOVH5ax/p/CkIO7KI4OyFJTQ==",
"funding": [
"https://github.com/sponsors/broofa"
],
"bin": {
"mime": "bin/cli.js"
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">=16"
"node": ">= 0.6"
}
},
"node_modules/mimic-fn": {

View File

@@ -1,6 +1,6 @@
{
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha8",
"version": "0.3.6-alpha.12",
"description": "Software development kit to facilitate packaging services for StartOS",
"main": "./package/lib/index.js",
"types": "./package/lib/index.d.ts",
@@ -15,7 +15,7 @@
},
"scripts": {
"test": "jest -c ./jest.config.js --coverage",
"buildOutput": "ts-node ./lib/test/makeOutput.ts && npx prettier --write '**/*.ts'",
"buildOutput": "ts-node ./lib/test/makeOutput.ts && npx prettier --write \"**/*.ts\"",
"check": "tsc --noEmit",
"tsc": "tsc"
},
@@ -32,7 +32,7 @@
"dependencies": {
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"mime": "^4.0.3",
"mime-types": "^2.1.35",
"ts-matches": "^5.5.1",
"yaml": "^2.2.2",
"@iarna/toml": "^2.2.5",

View File

@@ -48,6 +48,8 @@ npm i
npm run build:deps
```
> Note if you are on **Windows** you need to install `make` for these scripts to work. Easiest way to do so is to install [Chocolatey](https://chocolatey.org/install) and then run `choco install make`.
#### Copy `config-sample.json` to a new file `config.json`.
```sh

View File

@@ -17,12 +17,6 @@
"integrations": {},
"type": "angular",
"root": "projects/setup-wizard"
},
"diagnostic-ui": {
"name": "diagnostic-ui",
"integrations": {},
"type": "angular",
"root": "projects/diagnostic-ui"
}
},
"defaultProject": "ui"

View File

@@ -4,7 +4,6 @@ module.exports = {
'projects/ui/**/*.ts': () => 'npm run check:ui',
'projects/shared/**/*.ts': () => 'npm run check:shared',
'projects/marketplace/**/*.ts': () => 'npm run check:marketplace',
'projects/diagnostic-ui/**/*.ts': () => 'npm run check:dui',
'projects/install-wizard/**/*.ts': () => 'npm run check:install-wiz',
'projects/setup-wizard/**/*.ts': () => 'npm run check:setup',
}

View File

@@ -6,14 +6,13 @@
"license": "MIT",
"scripts": {
"ng": "ng",
"check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:install-wiz && npm run check:setup && npm run check:dui",
"check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:install-wiz && npm run check:setup",
"check:shared": "tsc --project projects/shared/tsconfig.json --noEmit --skipLibCheck",
"check:marketplace": "tsc --project projects/marketplace/tsconfig.json --noEmit --skipLibCheck",
"check:dui": "tsc --project projects/diagnostic-ui/tsconfig.json --noEmit --skipLibCheck",
"check:install-wiz": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
"build:deps": "rm -rf .angular/cache && (cd ../patch-db/client && npm ci && npm run build) && (cd ../sdk && make bundle)",
"build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)",
"build:install-wiz": "ng run install-wizard:build",
"build:setup": "ng run setup-wizard:build",
"build:ui": "ng run ui:build",

View File

@@ -83,7 +83,7 @@ export class SuccessPage {
await this.api.exit()
}
} catch (e: any) {
await this.errorService.handleError(e)
this.errorService.handleError(e)
}
}

View File

@@ -2,7 +2,7 @@ export type WorkspaceConfig = {
gitHash: string
useMocks: boolean
enableWidgets: boolean
// each key corresponds to a project and values adjust settings for that project, eg: ui, install-wizard, setup-wizard, diagnostic-ui
// each key corresponds to a project and values adjust settings for that project, eg: ui, install-wizard, setup-wizard
ui: {
api: {
url: string

View File

@@ -65,7 +65,6 @@ const ICONS = [
'options-outline',
'pencil',
'phone-portrait-outline',
'play-circle-outline',
'play-outline',
'power',
'pricetag-outline',

View File

@@ -149,14 +149,15 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
}
private process(operations: Operation[]) {
operations.forEach(({ op, path }) => {
const control = this.form.get(path.substring(1).split('/'))
operations.forEach(operation => {
const control = this.form.get(operation.path.substring(1).split('/'))
if (!control || !control.parent) return
if (op !== 'remove') {
if (operation.op === 'add' || operation.op === 'replace') {
control.markAsDirty()
control.markAsTouched()
control.setValue(operation.value)
}
control.parent.markAsDirty()

View File

@@ -16,7 +16,7 @@ import { compare } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { catchError, defer, EMPTY, endWith, firstValueFrom, map } from 'rxjs'
import { InvalidService } from 'src/app/components/form/invalid.service'
import { ActionDepComponent } from 'src/app/modals/action-dep.component'
import { ActionRequestInfoComponent } from 'src/app/modals/action-request-input.component'
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -25,23 +25,29 @@ import * as json from 'fast-json-patch'
import { ActionService } from '../services/action.service'
import { ActionButton, FormComponent } from '../components/form.component'
export interface PackageActionData {
readonly pkgInfo: {
export type PackageActionData = {
pkgInfo: {
id: string
title: string
icon: string
mainStatus: T.MainStatus['main']
}
readonly actionInfo: {
actionInfo: {
id: string
warning: string | null
metadata: T.ActionMetadata
}
readonly dependentInfo?: {
title: string
requestInfo?: {
dependentId?: string
request: T.ActionRequest
}
}
@Component({
template: `
<div class="service-title">
<img [src]="pkgInfo.icon" alt="" />
<h4>{{ pkgInfo.title }}</h4>
</div>
<ng-container *ngIf="res$ | async as res; else loading">
<tui-notification *ngIf="error" status="error">
<div [innerHTML]="error"></div>
@@ -52,13 +58,11 @@ export interface PackageActionData {
<div [innerHTML]="warning"></div>
</tui-notification>
<action-dep
*ngIf="dependentInfo"
[pkgTitle]="pkgInfo.title"
[depTitle]="dependentInfo.title"
<action-request-info
*ngIf="requestInfo"
[originalValue]="res.originalValue || {}"
[operations]="res.operations || []"
></action-dep>
></action-request-info>
<app-form
tuiMode="onDark"
@@ -87,7 +91,19 @@ export interface PackageActionData {
`
tui-notification {
font-size: 1rem;
margin-bottom: 1rem;
margin-bottom: 1.4rem;
}
.service-title {
display: inline-flex;
align-items: center;
margin-bottom: 1.4rem;
img {
height: 20px;
margin-right: 4px;
}
h4 {
margin: 0;
}
}
`,
],
@@ -98,7 +114,7 @@ export interface PackageActionData {
TuiNotificationModule,
TuiButtonModule,
TuiModeModule,
ActionDepComponent,
ActionRequestInfoComponent,
UiPipeModule,
FormComponent,
],
@@ -106,9 +122,9 @@ export interface PackageActionData {
})
export class ActionInputModal {
readonly actionId = this.context.data.actionInfo.id
readonly warning = this.context.data.actionInfo.warning
readonly warning = this.context.data.actionInfo.metadata.warning
readonly pkgInfo = this.context.data.pkgInfo
readonly dependentInfo = this.context.data.dependentInfo
readonly requestInfo = this.context.data.requestInfo
buttons: ActionButton<any>[] = [
{
@@ -131,12 +147,12 @@ export class ActionInputModal {
return {
spec: res.spec,
originalValue,
operations: this.dependentInfo?.request.input
operations: this.requestInfo?.request.input
? compare(
originalValue,
JSON.parse(JSON.stringify(originalValue)),
utils.deepMerge(
originalValue,
this.dependentInfo.request.input.value,
JSON.parse(JSON.stringify(originalValue)),
this.requestInfo.request.input.value,
) as object,
)
: null,
@@ -159,15 +175,7 @@ export class ActionInputModal {
async execute(input: object) {
if (await this.checkConflicts(input)) {
const res = await firstValueFrom(this.res$)
return this.actionService.execute(this.pkgInfo.id, this.actionId, {
prev: {
spec: res.spec,
value: res.originalValue,
},
curr: input,
})
return this.actionService.execute(this.pkgInfo.id, this.actionId, input)
}
}
@@ -181,6 +189,7 @@ export class ActionInputModal {
Object.values(packages[id].requestedActions).some(
({ request, active }) =>
!active &&
request.severity === 'critical' &&
request.packageId === this.pkgInfo.id &&
request.actionId === this.actionId &&
request.when?.condition === 'input-not-matches' &&

View File

@@ -11,14 +11,10 @@ import { CommonModule } from '@angular/common'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'action-dep',
selector: 'action-request-info',
template: `
<tui-notification>
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
{{ pkgTitle }}
</h3>
The following modifications have been made to {{ pkgTitle }} to satisfy
{{ depTitle }}:
<tui-notification *ngIf="diff.length">
The following modifications were made:
<ul>
<li *ngFor="let d of diff" [innerHTML]="d"></li>
</ul>
@@ -27,14 +23,15 @@ import { TuiNotificationModule } from '@taiga-ui/core'
standalone: true,
imports: [CommonModule, TuiNotificationModule],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
tui-notification {
margin-bottom: 1.5rem;
}
`,
],
})
export class ActionDepComponent implements OnInit {
@Input()
pkgTitle = ''
@Input()
depTitle = ''
export class ActionRequestInfoComponent implements OnInit {
@Input()
originalValue: object = {}
@@ -68,15 +65,15 @@ export class ActionDepComponent implements OnInit {
private getMessage(operation: Operation): string {
switch (operation.op) {
case 'add':
return `Added ${this.getNewValue(operation.value)}`
return `added ${this.getNewValue(operation.value)}`
case 'remove':
return `Removed ${this.getOldValue(operation.path)}`
return `removed ${this.getOldValue(operation.path)}`
case 'replace':
return `Changed from ${this.getOldValue(
return `changed from ${this.getOldValue(
operation.path,
)} to ${this.getNewValue(operation.value)}`
default:
return `Unknown operation`
return `Unknown operation` // unreachable
}
}

View File

@@ -0,0 +1,54 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiFadeModule, TuiTitleModule } from '@taiga-ui/experimental'
import { TuiAccordionModule } from '@taiga-ui/kit'
import { ActionSuccessItemComponent } from './action-success-item.component'
@Component({
standalone: true,
selector: 'app-action-success-group',
template: `
<p *ngFor="let item of value?.value">
<app-action-success-item
*ngIf="isSingle(item)"
[value]="item"
></app-action-success-item>
<tui-accordion-item *ngIf="!isSingle(item)" size="s">
<div tuiFade>{{ item.name }}</div>
<ng-template tuiAccordionItemContent>
<app-action-success-group [value]="item"></app-action-success-group>
</ng-template>
</tui-accordion-item>
</p>
`,
styles: [
`
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiTitleModule,
ActionSuccessItemComponent,
TuiAccordionModule,
TuiFadeModule,
],
})
export class ActionSuccessGroupComponent {
@Input()
value?: T.ActionResultV1 & { type: 'object' }
isSingle(
value: T.ActionResultV1,
): value is T.ActionResultV1 & { type: 'string' } {
return value.type === 'string'
}
}

View File

@@ -0,0 +1,184 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
ElementRef,
inject,
Input,
TemplateRef,
ViewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { T } from '@start9labs/start-sdk'
import {
TuiDialogService,
TuiLabelModule,
TuiTextfieldComponent,
TuiTextfieldControllerModule,
} from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiInputModule } from '@taiga-ui/kit'
import { QrCodeModule } from 'ng-qrcode'
import { ActionSuccessGroupComponent } from './action-success-group.component'
@Component({
standalone: true,
selector: 'app-action-success-item',
template: `
<p *ngIf="!parent" class="qr">
<ng-container *ngTemplateOutlet="qr"></ng-container>
</p>
<label [tuiLabel]="value.description">
<tui-input
[readOnly]="true"
[ngModel]="value.value"
[tuiTextfieldCustomContent]="actions"
>
<input
tuiTextfield
[style.border-inline-end-width.rem]="border"
[type]="value.masked && masked ? 'password' : 'text'"
/>
</tui-input>
</label>
<ng-template #actions>
<button
*ngIf="value.masked"
tuiIconButton
appearance="icon"
size="s"
type="button"
tabindex="-1"
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
[style.pointer-events]="'auto'"
(click)="masked = !masked"
>
Reveal/Hide
</button>
<button
*ngIf="value.copyable"
tuiIconButton
appearance="icon"
size="s"
type="button"
tabindex="-1"
iconLeft="tuiIconCopy"
[style.pointer-events]="'auto'"
(click)="copy()"
>
Copy
</button>
<button
*ngIf="value.qr && parent"
tuiIconButton
appearance="icon"
size="s"
type="button"
tabindex="-1"
iconLeft="tuiIconGrid"
[style.pointer-events]="'auto'"
(click)="show(qr)"
>
Show QR
</button>
</ng-template>
<ng-template #qr>
<qr-code
[value]="value.value"
[style.filter]="value.masked && masked ? 'blur(0.5rem)' : null"
size="350"
></qr-code>
<button
*ngIf="value.masked && masked"
tuiIconButton
class="reveal"
iconLeft="tuiIconEye"
[style.border-radius.%]="100"
(click)="masked = false"
>
Reveal
</button>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
@import '@taiga-ui/core/styles/taiga-ui-local';
.reveal {
@include center-all();
}
.qr {
position: relative;
text-align: center;
}
`,
],
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiTextfieldControllerModule,
TuiButtonModule,
QrCodeModule,
TuiLabelModule,
],
})
export class ActionSuccessItemComponent {
@ViewChild(TuiTextfieldComponent, { read: ElementRef })
private readonly input!: ElementRef<HTMLInputElement>
private readonly dialogs = inject(TuiDialogService)
readonly parent = inject(ActionSuccessGroupComponent, {
optional: true,
})
@Input()
value!: T.ActionResultV1 & { type: 'string' }
masked = true
get border(): number {
let border = 0
if (this.value.masked) {
border += 2
}
if (this.value.copyable) {
border += 2
}
if (this.value.qr && this.parent) {
border += 2
}
return border
}
show(template: TemplateRef<any>) {
const masked = this.masked
this.masked = this.value.masked
this.dialogs
.open(template, { label: 'Scan this QR', size: 's' })
.subscribe({
complete: () => (this.masked = masked),
})
}
copy() {
const el = this.input.nativeElement
if (!el) {
return
}
el.type = 'text'
el.focus()
el.select()
el.ownerDocument.execCommand('copy')
el.type = this.masked && this.value.masked ? 'password' : 'text'
}
}

View File

@@ -1,12 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { ActionSuccessPage } from './action-success.page'
import { QrCodeModule } from 'ng-qrcode'
@NgModule({
declarations: [ActionSuccessPage],
imports: [CommonModule, IonicModule, QrCodeModule],
exports: [ActionSuccessPage],
})
export class ActionSuccessPageModule {}

View File

@@ -1,35 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>Execution Complete</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()" class="enter-click">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<h2 class="ion-padding">{{ actionRes.message }}</h2>
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 48px 0">
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
<qr-code [value]="actionRes.value" size="240"></qr-code>
</div>
<p *ngIf="!actionRes.copyable">{{ actionRes.value }}</p>
<a
*ngIf="actionRes.copyable"
style="cursor: copy"
(click)="copy(actionRes.value)"
>
<b>{{ actionRes.value }}</b>
<sup>
<ion-icon
name="copy-outline"
style="padding-left: 6px; font-size: small"
></ion-icon>
</sup>
</a>
</div>
</ion-content>

View File

@@ -1,39 +1,36 @@
import { Component, Input } from '@angular/core'
import { ModalController, ToastController } from '@ionic/angular'
import { copyToClipboard } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { CommonModule } from '@angular/common'
import { Component, inject } from '@angular/core'
import { TuiDialogContext, TuiTextfieldControllerModule } from '@taiga-ui/core'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { RR } from 'src/app/services/api/api.types'
import { ActionSuccessGroupComponent } from './action-success-group.component'
import { ActionSuccessItemComponent } from './action-success-item.component'
@Component({
selector: 'action-success',
templateUrl: './action-success.page.html',
styleUrls: ['./action-success.page.scss'],
standalone: true,
template: `
<ng-container tuiTextfieldSize="m" [tuiTextfieldLabelOutside]="true">
<app-action-success-item
*ngIf="item"
[value]="item"
></app-action-success-item>
<app-action-success-group
*ngIf="group"
[value]="group"
></app-action-success-group>
</ng-container>
`,
imports: [
CommonModule,
ActionSuccessGroupComponent,
ActionSuccessItemComponent,
TuiTextfieldControllerModule,
],
})
export class ActionSuccessPage {
@Input()
actionRes!: T.ActionResult
readonly data =
inject<TuiDialogContext<void, RR.ActionRes>>(POLYMORPHEUS_CONTEXT).data
constructor(
private readonly modalCtrl: ModalController,
private readonly toastCtrl: ToastController,
) {}
async copy(address: string) {
let message = ''
await copyToClipboard(address || '').then(success => {
message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
async dismiss() {
return this.modalCtrl.dismiss()
}
readonly item = this.data?.type === 'string' ? this.data : null
readonly group = this.data?.type === 'object' ? this.data : null
}

View File

@@ -5,7 +5,6 @@ import { IonicModule } from '@ionic/angular'
import { AppActionsPage, AppActionsItemComponent } from './app-actions.page'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { SharedPipesModule } from '@start9labs/shared'
import { ActionSuccessPageModule } from 'src/app/modals/action-success/action-success.module'
const routes: Routes = [
{
@@ -21,7 +20,6 @@ const routes: Routes = [
RouterModule.forChild(routes),
QRComponentModule,
SharedPipesModule,
ActionSuccessPageModule,
],
declarations: [AppActionsPage, AppActionsItemComponent],
})

View File

@@ -37,8 +37,8 @@
<app-actions-item
*ngFor="let action of pkg.actions"
[action]="action"
icon="play-circle-outline"
(click)="handleAction(pkg.mainStatus, pkg.manifest, action)"
icon="play-outline"
(click)="handleAction(pkg.mainStatus, pkg.icon, pkg.manifest, action)"
></app-actions-item>
</ion-item-group>
</ion-content>

View File

@@ -21,13 +21,12 @@ export class AppActionsPage {
filter(pkg => pkg.stateInfo.state === 'installed'),
map(pkg => ({
mainStatus: pkg.status.main,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.keys(pkg.actions)
.filter(id => id !== 'config')
.map(id => ({
id,
...pkg.actions[id],
})),
actions: Object.keys(pkg.actions).map(id => ({
id,
...pkg.actions[id],
})),
})),
)
@@ -40,13 +39,14 @@ export class AppActionsPage {
async handleAction(
mainStatus: T.MainStatus['main'],
icon: string,
manifest: T.Manifest,
action: T.ActionMetadata & { id: string },
) {
this.actionService.present(
{ id: manifest.id, title: manifest.title, mainStatus },
{ id: action.id, metadata: action },
)
this.actionService.present({
pkgInfo: { id: manifest.id, title: manifest.title, icon, mainStatus },
actionInfo: { id: action.id, metadata: action },
})
}
async rebuild(id: string) {
@@ -76,7 +76,7 @@ export class AppActionsItemComponent {
get disabledText() {
return (
typeof this.action.visibility === 'object' &&
this.action.visibility.disabled.reason
this.action.visibility.disabled
)
}
}

View File

@@ -1,26 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppMetricsPage } from './app-metrics.page'
import { SharedPipesModule } from '@start9labs/shared'
import { SkeletonListComponentModule } from 'src/app/components/skeleton-list/skeleton-list.component.module'
const routes: Routes = [
{
path: '',
component: AppMetricsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
SkeletonListComponentModule,
],
declarations: [AppMetricsPage],
})
export class AppMetricsPageModule {}

View File

@@ -1,25 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Monitor</ion-title>
<ion-title slot="end">
<ion-spinner name="dots" class="fader"></ion-spinner>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding with-widgets">
<skeleton-list *ngIf="loading"></skeleton-list>
<ion-item-group *ngIf="!loading">
<ion-item *ngFor="let metric of metrics | keyvalue : asIsOrder">
<ion-label>{{ metric.key }}</ion-label>
<ion-note *ngIf="metric.value" slot="end" class="metric-note">
<ion-text style="color: white">
{{ metric.value.value }} {{ metric.value.unit }}
</ion-text>
</ion-note>
</ion-item>
</ion-item-group>
</ion-content>

View File

@@ -1,3 +0,0 @@
.metric-note {
font-size: 16px;
}

View File

@@ -1,59 +0,0 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ErrorService, getPkgId, pauseFor } from '@start9labs/shared'
import { Metric } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
selector: 'app-metrics',
templateUrl: './app-metrics.page.html',
styleUrls: ['./app-metrics.page.scss'],
})
export class AppMetricsPage {
loading = true
readonly pkgId = getPkgId(this.route)
going = false
metrics?: Metric
constructor(
private readonly route: ActivatedRoute,
private readonly errorService: ErrorService,
private readonly embassyApi: ApiService,
) {}
ngOnInit() {
this.startDaemon()
}
ngOnDestroy() {
this.stopDaemon()
}
async startDaemon(): Promise<void> {
this.going = true
while (this.going) {
const startTime = Date.now()
await this.getMetrics()
await pauseFor(Math.max(4000 - (Date.now() - startTime), 0))
}
}
stopDaemon() {
this.going = false
}
async getMetrics(): Promise<void> {
try {
this.metrics = await this.embassyApi.getPkgMetrics({ id: this.pkgId })
} catch (e: any) {
this.errorService.handleError(e)
this.stopDaemon()
} finally {
this.loading = false
}
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -1,32 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppPropertiesPage } from './app-properties.page'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { MaskPipeModule } from 'src/app/pipes/mask/mask.module'
import {
SharedPipesModule,
TextSpinnerComponentModule,
} from '@start9labs/shared'
const routes: Routes = [
{
path: '',
component: AppPropertiesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
QRComponentModule,
SharedPipesModule,
TextSpinnerComponentModule,
MaskPipeModule,
],
declarations: [AppPropertiesPage],
})
export class AppPropertiesPageModule {}

View File

@@ -1,119 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Properties</ion-title>
<ion-buttons *ngIf="!loading" slot="end">
<ion-button (click)="refresh()">
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top with-widgets">
<text-spinner
*ngIf="loading; else loaded"
text="Loading Properties"
></text-spinner>
<ng-template #loaded>
<!-- not running -->
<ion-item *ngIf="stopped$ | async" class="ion-margin-bottom">
<ion-label>
<p>
<ion-text color="warning">
Service is stopped. Information on this page could be inaccurate.
</ion-text>
</p>
</ion-label>
</ion-item>
<!-- no properties -->
<ion-item *ngIf="properties | empty">
<ion-label>
<p>No properties.</p>
</ion-label>
</ion-item>
<!-- properties -->
<ion-item-group *ngIf="!(properties | empty)">
<div *ngFor="let prop of node | keyvalue: asIsOrder">
<!-- object -->
<ion-item
button
detail="true"
*ngIf="prop.value.type === 'object'"
(click)="goToNested(prop.key)"
>
<ion-button
*ngIf="prop.value.description"
fill="clear"
slot="start"
(click)="presentDescription(prop, $event)"
>
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>
<h2>{{ prop.key }}</h2>
</ion-label>
</ion-item>
<!-- not object -->
<ion-item *ngIf="prop.value.type === 'string'">
<ion-button
*ngIf="prop.value.description"
fill="clear"
slot="start"
(click)="presentDescription(prop, $event)"
>
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>
<h2>{{ prop.key }}</h2>
<p class="courier-new">
{{ prop.value.masked && !unmasked[prop.key] ? (prop.value.value |
mask : 64) : prop.value.value }}
</p>
</ion-label>
<div slot="end" *ngIf="prop.value.copyable || prop.value.qr">
<ion-button
*ngIf="prop.value.masked"
fill="clear"
(click)="toggleMask(prop.key)"
>
<ion-icon
slot="icon-only"
[name]="unmasked[prop.key] ? 'eye-off-outline' : 'eye-outline'"
size="small"
></ion-icon>
</ion-button>
<ion-button
*ngIf="prop.value.qr"
fill="clear"
(click)="showQR(prop.value.value)"
>
<ion-icon
slot="icon-only"
name="qr-code-outline"
size="small"
></ion-icon>
</ion-button>
<ion-button
*ngIf="prop.value.copyable"
fill="clear"
(click)="copy(prop.value.value)"
>
<ion-icon
slot="icon-only"
name="copy-outline"
size="small"
></ion-icon>
</ion-button>
</div>
</ion-item>
</div>
</ion-item-group>
</ng-template>
</ion-content>

View File

@@ -1,3 +0,0 @@
.metric-note {
font-size: 16px;
}

View File

@@ -1,147 +0,0 @@
import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import {
AlertController,
IonBackButtonDelegate,
ModalController,
NavController,
ToastController,
} from '@ionic/angular'
import { copyToClipboard, ErrorService, getPkgId } from '@start9labs/shared'
import { TuiDestroyService } from '@taiga-ui/cdk'
import { getValueByPointer } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { map, takeUntil } from 'rxjs/operators'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PackageProperties } from 'src/app/util/properties.util'
@Component({
selector: 'app-properties',
templateUrl: './app-properties.page.html',
styleUrls: ['./app-properties.page.scss'],
providers: [TuiDestroyService],
})
export class AppPropertiesPage {
loading = true
readonly pkgId = getPkgId(this.route)
pointer = ''
node: PackageProperties = {}
properties: PackageProperties = {}
unmasked: { [key: string]: boolean } = {}
stopped$ = this.patch
.watch$('packageData', this.pkgId, 'status', 'main')
.pipe(map(status => status === 'stopped'))
@ViewChild(IonBackButtonDelegate, { static: false })
backButton?: IonBackButtonDelegate
constructor(
private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService,
private readonly errorService: ErrorService,
private readonly alertCtrl: AlertController,
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
private readonly destroy$: TuiDestroyService,
) {}
ionViewDidEnter() {
if (!this.backButton) return
this.backButton.onClick = () => {
history.back()
}
}
async ngOnInit() {
await this.getProperties()
this.route.queryParams
.pipe(takeUntil(this.destroy$))
.subscribe(queryParams => {
if (queryParams['pointer'] === this.pointer) return
this.pointer = queryParams['pointer'] || ''
this.node = getValueByPointer(this.properties, this.pointer)
})
}
async refresh() {
await this.getProperties()
}
async presentDescription(
property: { key: string; value: PackageProperties[''] },
e: Event,
) {
e.stopPropagation()
const alert = await this.alertCtrl.create({
header: property.key,
message: property.value.description || undefined,
})
await alert.present()
}
async goToNested(key: string): Promise<any> {
this.navCtrl.navigateForward(`/services/${this.pkgId}/properties`, {
queryParams: {
pointer: `${this.pointer}/${key}/value`,
},
})
}
async copy(text: string): Promise<void> {
let message = ''
await copyToClipboard(text).then(success => {
message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
async showQR(text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
toggleMask(key: string) {
this.unmasked[key] = !this.unmasked[key]
}
private async getProperties(): Promise<void> {
this.loading = true
try {
this.properties = await this.embassyApi.getPackageProperties({
id: this.pkgId,
})
this.node = getValueByPointer(this.properties, this.pointer)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -19,6 +19,7 @@ import { AppShowMenuComponent } from './components/app-show-menu/app-show-menu.c
import { AppShowHealthChecksComponent } from './components/app-show-health-checks/app-show-health-checks.component'
import { AppShowAdditionalComponent } from './components/app-show-additional/app-show-additional.component'
import { AppShowErrorComponent } from './components/app-show-error/app-show-error.component'
import { AppShowActionRequestsComponent } from './components/app-show-action-requests/app-show-action-requests.component'
import { HealthColorPipe } from './pipes/health-color.pipe'
import { ToHealthChecksPipe } from './pipes/to-health-checks.pipe'
import { ToButtonsPipe } from './pipes/to-buttons.pipe'
@@ -45,6 +46,7 @@ const routes: Routes = [
AppShowHealthChecksComponent,
AppShowAdditionalComponent,
AppShowErrorComponent,
AppShowActionRequestsComponent,
],
imports: [
CommonModule,

View File

@@ -20,6 +20,12 @@
<ng-container
*ngIf="isInstalled(pkg) && status.primary !== 'backingUp' && status.primary !== 'error'"
>
<!-- ** action requests ** -->
<app-show-action-requests
[allPkgs]="pkgPlus.allPkgs"
[pkg]="pkg"
[manifest]="pkgPlus.manifest"
></app-show-action-requests>
<!-- ** health checks ** -->
<app-show-health-checks
*ngIf="pkg.status.main === 'running'"

View File

@@ -20,14 +20,13 @@ import {
import { combineLatest } from 'rxjs'
import {
getManifest,
getPackage,
isInstalled,
isInstalling,
isRestoring,
isUpdating,
} from 'src/app/util/get-package-data'
import { T } from '@start9labs/start-sdk'
import { ActionService } from 'src/app/services/action.service'
import { getDepDetails } from 'src/app/util/dep-info'
export interface DependencyInfo {
id: string
@@ -35,7 +34,7 @@ export interface DependencyInfo {
icon: string
version: string
errorText: string
actionText: string
actionText: string | null
action: () => any
}
@@ -60,6 +59,7 @@ export class AppShowPage {
const pkg = allPkgs[this.pkgId]
const manifest = getManifest(pkg)
return {
allPkgs,
pkg,
manifest,
dependencies: this.getDepInfo(pkg, manifest, allPkgs, depErrors),
@@ -75,7 +75,6 @@ export class AppShowPage {
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
private readonly depErrorService: DepErrorService,
private readonly actionService: ActionService,
) {}
showProgress(
@@ -95,32 +94,6 @@ export class AppShowPage {
)
}
private getDepDetails(
pkg: PackageDataEntry,
allPkgs: AllPackageData,
depId: string,
) {
const { title, icon, versionRange } = pkg.currentDependencies[depId]
if (
allPkgs[depId] &&
(allPkgs[depId].stateInfo.state === 'installed' ||
allPkgs[depId].stateInfo.state === 'updating')
) {
return {
title: allPkgs[depId].stateInfo.manifest!.title,
icon: allPkgs[depId].icon,
versionRange,
}
} else {
return {
title: title || depId,
icon: icon || 'assets/img/service-icons/fallback.png',
versionRange,
}
}
}
private getDepValues(
pkg: PackageDataEntry,
allPkgs: AllPackageData,
@@ -135,23 +108,16 @@ export class AppShowPage {
depErrors,
)
const { title, icon, versionRange } = this.getDepDetails(
pkg,
allPkgs,
depId,
)
const { title, icon, versionRange } = getDepDetails(pkg, allPkgs, depId)
return {
id: depId,
version: versionRange,
title,
icon,
errorText: errorText
? `${errorText}. ${manifest.title} will not work as expected.`
: '',
actionText: fixText || 'View',
action:
fixAction || (() => this.navCtrl.navigateForward(`/services/${depId}`)),
errorText: errorText ? errorText : '',
actionText: fixText,
action: fixAction,
}
}
@@ -165,28 +131,31 @@ export class AppShowPage {
let errorText: string | null = null
let fixText: string | null = null
let fixAction: (() => any) | null = null
let fixAction: () => any = () => {}
if (depError) {
if (depError.type === 'notInstalled') {
errorText = 'Not installed'
fixText = 'Install'
fixAction = () => this.fixDep(pkg, manifest, 'install', depId)
fixAction = () => this.installDep(pkg, manifest, depId)
} else if (depError.type === 'incorrectVersion') {
errorText = 'Incorrect version'
fixText = 'Update'
fixAction = () => this.fixDep(pkg, manifest, 'update', depId)
} else if (depError.type === 'configUnsatisfied') {
errorText = 'Config not satisfied'
fixText = 'Auto config'
fixAction = () => this.fixDep(pkg, manifest, 'configure', depId)
fixAction = () => this.installDep(pkg, manifest, depId)
} else if (depError.type === 'actionRequired') {
errorText = 'Action Required (see above)'
} else if (depError.type === 'notRunning') {
errorText = 'Not running'
fixText = 'Start'
fixAction = () => this.navCtrl.navigateForward(`/services/${depId}`)
} else if (depError.type === 'healthChecksFailed') {
errorText = 'Required health check not passing'
fixText = 'View'
fixAction = () => this.navCtrl.navigateForward(`/services/${depId}`)
} else if (depError.type === 'transitive') {
errorText = 'Dependency has a dependency issue'
fixText = 'View'
fixAction = () => this.navCtrl.navigateForward(`/services/${depId}`)
}
}
@@ -197,41 +166,6 @@ export class AppShowPage {
}
}
private async fixDep(
pkg: PackageDataEntry,
pkgManifest: T.Manifest,
action: 'install' | 'update' | 'configure',
depId: string,
) {
switch (action) {
case 'install':
case 'update':
return this.installDep(pkg, pkgManifest, depId)
case 'configure':
const depPkg = await getPackage(this.patch, depId)
if (!depPkg) return
const depManifest = getManifest(depPkg)
return this.actionService.present(
{
id: depId,
title: depManifest.title,
mainStatus: depPkg.status.main,
},
{ id: 'config', metadata: pkg.actions['config'] },
{
title: pkgManifest.title,
request: Object.values(pkg.requestedActions).find(
r =>
r.active &&
r.request.packageId === depId &&
r.request.actionId === 'config',
)!.request,
},
)
}
}
private async installDep(
pkg: PackageDataEntry,
pkgManifest: T.Manifest,

View File

@@ -0,0 +1,45 @@
<ng-container *ngIf="actionRequests.critical.length">
<ion-item-divider>Required Actions</ion-item-divider>
<ion-item
*ngFor="let request of actionRequests.critical"
button
(click)="handleAction(request)"
>
<ion-icon slot="start" name="warning-outline" color="warning"></ion-icon>
<ion-label>
<h2 class="highlighted">{{ request.actionName }}</h2>
<p *ngIf="request.dependency" class="dependency">
<span class="light">Service:</span>
<img [src]="request.dependency.icon" alt="" />
{{ request.dependency.title }}
</p>
<p>
<span class="light">Reason:</span>
{{ request.reason || 'no reason provided' }}
</p>
</ion-label>
</ion-item>
</ng-container>
<ng-container *ngIf="actionRequests.important.length">
<ion-item-divider>Requested Actions</ion-item-divider>
<ion-item
*ngFor="let request of actionRequests.important"
button
(click)="handleAction(request)"
>
<ion-icon slot="start" name="play-outline" color="warning"></ion-icon>
<ion-label>
<h2 class="highlighted">{{ request.actionName }}</h2>
<p *ngIf="request.dependency" class="dependency">
<span class="light">Service:</span>
<img [src]="request.dependency.icon" alt="" />
{{ request.dependency.title }}
</p>
<p>
<span class="light">Reason:</span>
{{ request.reason || 'no reason provided' }}
</p>
</ion-label>
</ion-item>
</ng-container>

View File

@@ -0,0 +1,16 @@
.light {
color: var(--ion-color-dark);
}
.highlighted {
color: var(--ion-color-dark);
font-weight: bold;
}
.dependency {
display: inline-flex;
img {
max-width: 16px;
margin: 0 2px 0 5px;
}
}

View File

@@ -0,0 +1,94 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { ActionService } from 'src/app/services/action.service'
import { getDepDetails } from 'src/app/util/dep-info'
@Component({
selector: 'app-show-action-requests',
templateUrl: './app-show-action-requests.component.html',
styleUrls: ['./app-show-action-requests.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowActionRequestsComponent {
@Input()
allPkgs!: Record<string, T.PackageDataEntry>
@Input()
pkg!: T.PackageDataEntry
@Input()
manifest!: T.Manifest
get actionRequests() {
const critical: (T.ActionRequest & {
actionName: string
dependency: {
title: string
icon: string
} | null
})[] = []
const important: (T.ActionRequest & {
actionName: string
dependency: {
title: string
icon: string
} | null
})[] = []
Object.values(this.pkg.requestedActions)
.filter(r => r.active)
.forEach(r => {
const self = r.request.packageId === this.manifest.id
const toReturn = {
...r.request,
actionName: self
? this.pkg.actions[r.request.actionId].name
: this.allPkgs[r.request.packageId]?.actions[r.request.actionId]
.name || 'Unknown Action',
dependency: self
? null
: getDepDetails(this.pkg, this.allPkgs, r.request.packageId),
}
if (r.request.severity === 'critical') {
critical.push(toReturn)
} else {
important.push(toReturn)
}
})
return { critical, important }
}
constructor(private readonly actionService: ActionService) {}
async handleAction(request: T.ActionRequest) {
const self = request.packageId === this.manifest.id
this.actionService.present({
pkgInfo: {
id: request.packageId,
title: self
? this.manifest.title
: getDepDetails(this.pkg, this.allPkgs, request.packageId).title,
mainStatus: self
? this.pkg.status.main
: this.allPkgs[request.packageId].status.main,
icon: self
? this.pkg.icon
: getDepDetails(this.pkg, this.allPkgs, request.packageId).icon,
},
actionInfo: {
id: request.actionId,
metadata:
request.packageId === this.manifest.id
? this.pkg.actions[request.actionId]
: this.allPkgs[request.packageId].actions[request.actionId],
},
requestInfo: {
request,
dependentId:
request.packageId === this.manifest.id ? undefined : this.manifest.id,
},
})
}
}

View File

@@ -1,6 +1,10 @@
<ion-item-divider>Dependencies</ion-item-divider>
<!-- dependencies are a subset of the pkg.manifest.dependencies that are currently required as determined by the service config -->
<ion-item button *ngFor="let dep of dependencies" (click)="dep.action()">
<ion-item
[button]="!!dep.actionText"
*ngFor="let dep of dependencies"
(click)="dep.action()"
>
<ion-thumbnail slot="start">
<img [src]="dep.icon" alt="" />
</ion-thumbnail>

View File

@@ -52,16 +52,6 @@
Start
</ion-button>
<ion-button
*ngIf="needsConfig(manifest.id, pkg.requestedActions)"
class="action-button"
color="warning"
(click)="presentModalConfig()"
>
<ion-icon slot="start" name="construct-outline"></ion-icon>
Configure
</ion-button>
<ion-button
*ngIf="pkgStatus && interfaces && (interfaces | hasUi) && hosts"
class="action-button"

View File

@@ -3,7 +3,6 @@ import { AlertController } from '@ionic/angular'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConnectionService } from 'src/app/services/connection.service'
import {
@@ -19,7 +18,6 @@ import {
getAllPackages,
getManifest,
isInstalled,
needsConfig,
} from 'src/app/util/get-package-data'
import { hasCurrentDeps } from 'src/app/util/has-deps'
@@ -39,7 +37,6 @@ export class AppShowStatusComponent {
PR = PrimaryRendering
isInstalled = isInstalled
needsConfig = needsConfig
constructor(
private readonly alertCtrl: AlertController,
@@ -49,7 +46,6 @@ export class AppShowStatusComponent {
private readonly launcherService: UiLauncherService,
readonly connection$: ConnectionService,
private readonly patch: PatchDB<DataModel>,
private readonly actionService: ActionService,
) {}
get interfaces(): PackageDataEntry['serviceInterfaces'] {
@@ -77,14 +73,11 @@ export class AppShowStatusComponent {
}
get canStart(): boolean {
return (
this.status.primary === 'stopped' &&
!Object.keys(this.pkg.requestedActions).length
)
return this.status.primary === 'stopped'
}
get sigtermTimeout(): string | null {
return this.pkgStatus?.main === 'stopping' ? '30s' : null // @dr-bonez TODO
return this.pkgStatus?.main === 'stopping' ? '30s' : null // @TODO Aiden
}
launchUi(
@@ -94,17 +87,6 @@ export class AppShowStatusComponent {
this.launcherService.launch(interfaces, hosts)
}
async presentModalConfig(): Promise<void> {
return this.actionService.present(
{
id: this.manifest.id,
title: this.manifest.title,
mainStatus: this.pkg.status.main,
},
{ id: 'config', metadata: this.pkg.actions['config'] },
)
}
async tryStart(): Promise<void> {
if (this.status.dependency === 'warning') {
const depErrMsg = `${this.manifest.title} has unmet dependencies. It will not work as expected.`
@@ -221,6 +203,7 @@ export class AppShowStatusComponent {
loader.unsubscribe()
}
}
private async presentAlertStart(message: string): Promise<boolean> {
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({

View File

@@ -1,6 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AlertController, ModalController, NavController } from '@ionic/angular'
import { ModalController, NavController } from '@ionic/angular'
import { MarkdownComponent } from '@start9labs/shared'
import {
DataModel,
@@ -8,10 +8,8 @@ import {
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { from, map, Observable, of } from 'rxjs'
import { from, map, Observable } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { ActionService } from 'src/app/services/action.service'
import { needsConfig } from 'src/app/util/get-package-data'
export interface Button {
title: string
@@ -33,8 +31,6 @@ export class ToButtonsPipe implements PipeTransform {
private readonly apiService: ApiService,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly actionService: ActionService,
private readonly alertCtrl: AlertController,
) {}
transform(pkg: PackageDataEntry<InstalledState>): Button[] {
@@ -51,50 +47,12 @@ export class ToButtonsPipe implements PipeTransform {
.watch$('ui', 'ackInstructions', manifest.id)
.pipe(map(seen => !seen)),
},
// config
{
action: async () =>
pkg.actions['config']
? this.actionService.present(
{
id: manifest.id,
title: manifest.title,
mainStatus: pkg.status.main,
},
{
id: 'config',
metadata: pkg.actions['config'],
},
)
: this.alertCtrl
.create({
header: 'No Config',
message: `No config options for ${manifest.title} v${manifest.version}`,
buttons: ['OK'],
})
.then(a => a.present()),
title: 'Config',
description: `Customize ${manifest.title}`,
icon: 'options-outline',
highlighted$: of(needsConfig(manifest.id, pkg.requestedActions)),
},
// properties
{
action: () =>
this.navCtrl.navigateForward(['properties'], {
relativeTo: this.route,
}),
title: 'Properties',
description:
'Runtime information, credentials, and other values of interest',
icon: 'briefcase-outline',
},
// actions
{
action: () =>
this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }),
title: 'Actions',
description: `Uninstall and other commands specific to ${manifest.title}`,
description: `All actions for ${manifest.title}`,
icon: 'flash-outline',
},
// interfaces

View File

@@ -36,20 +36,6 @@ const routes: Routes = [
loadChildren: () =>
import('./app-logs/app-logs.module').then(m => m.AppLogsPageModule),
},
{
path: ':pkgId/metrics',
loadChildren: () =>
import('./app-metrics/app-metrics.module').then(
m => m.AppMetricsPageModule,
),
},
{
path: ':pkgId/properties',
loadChildren: () =>
import('./app-properties/app-properties.module').then(
m => m.AppPropertiesPageModule,
),
},
]
@NgModule({

View File

@@ -1,9 +1,9 @@
import { Injectable } from '@angular/core'
import { AlertController, ModalController } from '@ionic/angular'
import { AlertController } from '@ionic/angular'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
@@ -31,28 +31,16 @@ const allowedStatuses = {
export class ActionService {
constructor(
private readonly api: ApiService,
private readonly modalCtrl: ModalController,
private readonly dialogs: TuiDialogService,
private readonly alertCtrl: AlertController,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly formDialog: FormDialogService,
) {}
async present(
pkgInfo: {
id: string
title: string
mainStatus: T.MainStatus['main']
},
actionInfo: {
id: string
metadata: T.ActionMetadata
},
dependentInfo?: {
title: string
request: T.ActionRequest
},
) {
async present(data: PackageActionData) {
const { pkgInfo, actionInfo } = data
if (
allowedStatuses[actionInfo.metadata.allowedStatuses].has(
pkgInfo.mainStatus,
@@ -61,36 +49,32 @@ export class ActionService {
if (actionInfo.metadata.hasInput) {
this.formDialog.open<PackageActionData>(ActionInputModal, {
label: actionInfo.metadata.name,
data: {
pkgInfo,
actionInfo: {
id: actionInfo.id,
warning: actionInfo.metadata.warning,
},
dependentInfo,
},
data,
})
} else {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: `Are you sure you want to execute action "${
actionInfo.metadata.name
}"? ${actionInfo.metadata.warning || ''}`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Execute',
handler: () => {
this.execute(pkgInfo.id, actionInfo.id)
if (actionInfo.metadata.warning) {
const alert = await this.alertCtrl.create({
header: 'Warning',
message: actionInfo.metadata.warning,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
cssClass: 'enter-click',
},
],
})
await alert.present()
{
text: 'Run',
handler: () => {
this.execute(pkgInfo.id, actionInfo.id)
},
cssClass: 'enter-click',
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
} else {
this.execute(pkgInfo.id, actionInfo.id)
}
}
} else {
const statuses = [...allowedStatuses[actionInfo.metadata.allowedStatuses]]
@@ -123,30 +107,24 @@ export class ActionService {
async execute(
packageId: string,
actionId: string,
inputs?: {
prev: RR.GetActionInputRes
curr: object
},
input?: object,
): Promise<boolean> {
const loader = this.loader.open('Executing action...').subscribe()
const loader = this.loader.open('Loading...').subscribe()
try {
const res = await this.api.runAction({
packageId,
actionId,
prev: inputs?.prev || null,
input: inputs?.curr || null,
input: input || null,
})
if (res) {
const successModal = await this.modalCtrl.create({
component: ActionSuccessPage,
componentProps: {
actionRes: res,
},
})
setTimeout(() => successModal.present(), 500)
this.dialogs
.open(new PolymorpheusComponent(ActionSuccessPage), {
label: res.name,
data: res,
})
.subscribe()
}
return true // needed to dismiss original modal/alert
} catch (e: any) {

View File

@@ -2,18 +2,13 @@ import {
InstalledState,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { Metric, NotificationLevel, RR, ServerNotifications } from './api.types'
import { NotificationLevel, RR, ServerNotifications } from './api.types'
import { BTC_ICON, LND_ICON, PROXY_ICON, REGISTRY_ICON } from './api-icons'
import { Log } from '@start9labs/shared'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { T, ISB, IST } from '@start9labs/start-sdk'
import { GetPackagesRes } from '@start9labs/marketplace'
const mockBlake3Commitment: T.Blake3Commitment = {
hash: 'fakehash',
size: 0,
}
const mockMerkleArchiveCommitment: T.MerkleArchiveCommitment = {
rootSighash: 'fakehash',
rootMaxsize: 0,
@@ -880,25 +875,6 @@ export module Mock {
}
}
export function getAppMetrics() {
const metr: Metric = {
Metric1: {
value: Math.random(),
unit: 'mi/b',
},
Metric2: {
value: Math.random(),
unit: '%',
},
Metric3: {
value: 10.1,
unit: '%',
},
}
return metr
}
export const ServerLogs: Log[] = [
{
timestamp: '2022-07-28T03:52:54.808769Z',
@@ -946,15 +922,6 @@ export module Mock {
},
}
export const ActionResponse: T.ActionResult = {
version: '0',
message:
'Password changed successfully. If you lose your new password, you will be lost forever.',
value: 'NewPassword1234!',
copyable: true,
qr: true,
}
export const SshKeys: RR.GetSSHKeysRes = [
{
createdAt: new Date().toISOString(),
@@ -1082,11 +1049,26 @@ export module Mock {
},
}
export const PackageProperties: RR.GetPackagePropertiesRes<2> = {
version: 2,
data: {
lndconnect: {
export const ActionRes: RR.ActionRes = {
version: '1',
type: 'string',
name: 'New Password',
description:
'Action was run successfully Action was run successfully Action was run successfully Action was run successfully Action was run successfully',
copyable: true,
qr: true,
masked: true,
value: 'iwejdoiewdhbew',
}
export const ActionProperties: RR.ActionRes = {
version: '1',
type: 'object',
name: 'Properties',
value: [
{
type: 'string',
name: 'LND Connect',
description: 'This is some information about the thing.',
copyable: true,
qr: true,
@@ -1094,45 +1076,50 @@ export module Mock {
value:
'lndconnect://udlyfq2mxa4355pt7cqlrdipnvk2tsl4jtsdw7zaeekenufwcev2wlad.onion:10009?cert=MIICJTCCAcugAwIBAgIRAOyq85fqAiA3U3xOnwhH678wCgYIKoZIzj0EAwIwODEfMB0GAkUEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMNTc0OTkwMzIyYzZlMB4XDTIwMTAyNjA3MzEyN1oXDTIxMTIyMTA3MzEyN1owODEfMB0GA1UEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMNTc0OTkwMzIyYzZlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKqfhAMMZdY-eFnU5P4bGrQTSx0lo7m8u4V0yYkzUM6jlql_u31_mU2ovLTj56wnZApkEjoPl6fL2yasZA2wiy6OBtTCBsjAOBgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH_BAUwAwEB_zAdBgNVHQ4EFgQUYQ9uIO6spltnVCx4rLFL5BvBF9IwWwYDVR0RBFQwUoIMNTc0OTkwMzIyYzZlgglsb2NhbGhvc3SCBHVuaXiCCnVuaXhwYWNrZXSCB2J1ZmNvbm6HBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAGHBKwSAAswCgYIKoZIzj0EAwIDSAAwRQIgVZH2Z2KlyAVY2Q2aIQl0nsvN-OEN49wreFwiBqlxNj4CIQD5_JbpuBFJuf81I5J0FQPtXY-4RppWOPZBb-y6-rkIUQ&macaroon=AgEDbG5kAusBAwoQuA8OUMeQ8Fr2h-f65OdXdRIBMBoWCgdhZGRyZXNzEgRyZWFkEgV3cml0ZRoTCgRpbmZvEgRyZWFkEgV3cml0ZRoXCghpbnZvaWNlcxIEcmVhZBIFd3JpdGUaFAoIbWFjYXJvb24SCGdlbmVyYXRlGhYKB21lc3NhZ2USBHJlYWQSBXdyaXRlGhcKCG9mZmNoYWluEgRyZWFkEgV3cml0ZRoWCgdvbmNoYWluEgRyZWFkEgV3cml0ZRoUCgVwZWVycxIEcmVhZBIFd3JpdGUaGAoGc2lnbmVyEghnZW5lcmF0ZRIEcmVhZAAABiCYsRUoUWuAHAiCSLbBR7b_qULDSl64R8LIU2aqNIyQfA',
},
Nested: {
{
type: 'object',
name: 'Nested Stuff',
description: 'This is a nested thing metric',
value: {
'Last Name': {
value: [
{
type: 'string',
name: 'Last Name',
description: 'The last name of the user',
copyable: true,
qr: true,
masked: false,
value: 'Hill',
},
Age: {
{
type: 'string',
name: 'Age',
description: 'The age of the user',
copyable: false,
qr: false,
masked: false,
value: '35',
},
Password: {
{
type: 'string',
name: 'Password',
description: 'A secret password',
copyable: true,
qr: false,
masked: true,
value: 'password123',
},
},
],
},
'Another Value': {
{
type: 'string',
name: 'Another Value',
description: 'Some more information about the service.',
copyable: false,
qr: true,
masked: false,
value: 'https://guessagain.com',
},
},
],
}
export const getActionInputSpec = async (): Promise<IST.InputSpec> =>
@@ -1692,7 +1679,7 @@ export module Mock {
},
actions: {
config: {
name: 'Bitcoin Config',
name: 'Set Config',
description: 'edit bitcoin.conf',
warning: null,
visibility: 'enabled',
@@ -1700,6 +1687,25 @@ export module Mock {
hasInput: true,
group: null,
},
properties: {
name: 'View Properties',
description: 'view important information about Bitcoin',
warning: null,
visibility: 'enabled',
allowedStatuses: 'any',
hasInput: false,
group: null,
},
test: {
name: 'Do Another Thing',
description:
'An example of an action that shows a warning and takes no input',
warning: 'careful running this action',
visibility: 'enabled',
allowedStatuses: 'only-running',
hasInput: false,
group: null,
},
},
serviceInterfaces: {
ui: {
@@ -1859,7 +1865,27 @@ export module Mock {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
requestedActions: {},
requestedActions: {
'bitcoind-config': {
request: {
packageId: 'bitcoind',
actionId: 'config',
severity: 'critical',
reason:
'You must run Config before starting Bitcoin for the first time',
},
active: true,
},
'bitcoind-properties': {
request: {
packageId: 'bitcoind',
actionId: 'properties',
severity: 'important',
reason: 'Check out all the info about your Bitcoin node',
},
active: true,
},
},
}
export const bitcoinProxy: PackageDataEntry<InstalledState> = {
@@ -1992,7 +2018,27 @@ export module Mock {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
requestedActions: {},
requestedActions: {
'bitcoind/config': {
active: true,
request: {
packageId: 'bitcoind',
actionId: 'config',
severity: 'critical',
reason: 'LND likes BTC a certain way',
input: {
kind: 'partial',
value: {
color: '#ffffff',
rpcsettings: {
rpcuser: 'lnd',
},
testnet: false,
},
},
},
},
},
}
export const LocalPkgs: { [key: string]: PackageDataEntry<InstalledState> } =

View File

@@ -1,5 +1,4 @@
import { Dump } from 'patch-db-client'
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
import { IST, T } from '@start9labs/start-sdk'
@@ -209,19 +208,12 @@ export module RR {
// package
export type GetPackagePropertiesReq = { id: string } // package.properties
export type GetPackagePropertiesRes<T extends number> =
PackagePropertiesVersioned<T>
export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = LogsRes
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
export type FollowPackageLogsRes = FollowServerLogsRes
export type GetPackageMetricsReq = { id: string } // package.metrics
export type GetPackageMetricsRes = Metric
export type InstallPackageReq = T.InstallParams
export type InstallPackageRes = null
@@ -231,13 +223,12 @@ export module RR {
value: object | null
}
export type RunActionReq = {
export type ActionReq = {
packageId: string
actionId: string
prev: GetActionInputRes | null
input: object | null
} // package.action.run
export type RunActionRes = T.ActionResult | null
export type ActionRes = (T.ActionResult & { version: '1' }) | null
export type RestorePackagesReq = {
// package.backup.restore
@@ -494,7 +485,7 @@ export type DependencyError =
| DependencyErrorNotInstalled
| DependencyErrorNotRunning
| DependencyErrorIncorrectVersion
| DependencyErrorConfigUnsatisfied
| DependencyErrorActionRequired
| DependencyErrorHealthChecksFailed
| DependencyErrorTransitive
@@ -512,8 +503,8 @@ export interface DependencyErrorIncorrectVersion {
received: string // version
}
export interface DependencyErrorConfigUnsatisfied {
type: 'configUnsatisfied'
export interface DependencyErrorActionRequired {
type: 'actionRequired'
}
export interface DependencyErrorHealthChecksFailed {

View File

@@ -113,10 +113,6 @@ export abstract class ApiService {
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes>
abstract getPkgMetrics(
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes>
abstract updateServer(url?: string): Promise<RR.UpdateServerRes>
abstract restartServer(
@@ -215,10 +211,6 @@ export abstract class ApiService {
// package
abstract getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']>
abstract getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes>
@@ -235,7 +227,7 @@ export abstract class ApiService {
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes>
abstract runAction(params: RR.RunActionReq): Promise<RR.RunActionRes>
abstract runAction(params: RR.ActionReq): Promise<RR.ActionRes>
abstract restorePackages(
params: RR.RestorePackagesReq,

View File

@@ -10,7 +10,6 @@ import {
import { PATCH_CACHE } from 'src/app/services/patch-db/patch-db-source'
import { ApiService } from './embassy-api.service'
import { RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { ConfigService } from '../config.service'
import { webSocket } from 'rxjs/webSocket'
import { Observable, filter, firstValueFrom } from 'rxjs'
@@ -436,14 +435,6 @@ export class LiveApiService extends ApiService {
// package
async getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']> {
return this.rpcRequest({ method: 'package.properties', params }).then(
parsePropertiesPermissive,
)
}
async getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes> {
@@ -456,12 +447,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.logs.follow', params })
}
async getPkgMetrics(
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes> {
return this.rpcRequest({ method: 'package.metrics', params })
}
async installPackage(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes> {
@@ -474,7 +459,7 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.action.get-input', params })
}
async runAction(params: RR.RunActionReq): Promise<RR.RunActionRes> {
async runAction(params: RR.ActionReq): Promise<RR.ActionRes> {
return this.rpcRequest({ method: 'package.action.run', params })
}

View File

@@ -17,7 +17,6 @@ import {
UpdatingState,
} from 'src/app/services/patch-db/data-model'
import { CifsBackupTarget, RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { Mock } from './api.fixures'
import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
import {
@@ -368,13 +367,6 @@ export class MockApiService extends ApiService {
return Mock.getServerMetrics()
}
async getPkgMetrics(
params: RR.GetServerMetricsReq,
): Promise<RR.GetPackageMetricsRes> {
await pauseFor(2000)
return Mock.getAppMetrics()
}
async updateServer(url?: string): Promise<RR.UpdateServerRes> {
await pauseFor(2000)
const initialProgress = {
@@ -707,13 +699,6 @@ export class MockApiService extends ApiService {
// package
async getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']> {
await pauseFor(2000)
return parsePropertiesPermissive(Mock.PackageProperties)
}
async getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes> {
@@ -795,9 +780,23 @@ export class MockApiService extends ApiService {
}
}
async runAction(params: RR.RunActionReq): Promise<RR.RunActionRes> {
async runAction(params: RR.ActionReq): Promise<RR.ActionRes> {
await pauseFor(2000)
return Mock.ActionResponse
if (params.actionId === 'properties') {
return Mock.ActionProperties
} 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.ActionRes
}
}
async restorePackages(

View File

@@ -61,6 +61,7 @@ export const mockPatchData: DataModel = {
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
versionCompat: '>=0.3.0 <=0.3.6',
postInitMigrationTodos: [],
statusInfo: {
backupProgress: null,
updated: false,
@@ -82,7 +83,6 @@ export const mockPatchData: DataModel = {
selected: null,
lastRegion: null,
},
postInitMigrationTodos: [],
},
packageData: {
bitcoind: {
@@ -107,7 +107,7 @@ export const mockPatchData: DataModel = {
// },
actions: {
config: {
name: 'Bitcoin Config',
name: 'Set Config',
description: 'edit bitcoin.conf',
warning: null,
visibility: 'enabled',
@@ -115,6 +115,25 @@ export const mockPatchData: DataModel = {
hasInput: true,
group: null,
},
properties: {
name: 'View Properties',
description: 'view important information about Bitcoin',
warning: null,
visibility: 'enabled',
allowedStatuses: 'any',
hasInput: false,
group: null,
},
test: {
name: 'Do Another Thing',
description:
'An example of an action that shows a warning and takes no input',
warning: 'careful running this action',
visibility: 'enabled',
allowedStatuses: 'only-running',
hasInput: false,
group: null,
},
},
serviceInterfaces: {
ui: {
@@ -274,7 +293,27 @@ export const mockPatchData: DataModel = {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
requestedActions: {},
requestedActions: {
'bitcoind-config': {
request: {
packageId: 'bitcoind',
actionId: 'config',
severity: 'critical',
reason:
'You must run Config before starting Bitcoin for the first time',
},
active: true,
},
'bitcoind-properties': {
request: {
packageId: 'bitcoind',
actionId: 'properties',
severity: 'important',
reason: 'Check out all the info about your Bitcoin node',
},
active: true,
},
},
},
lnd: {
stateInfo: {
@@ -364,7 +403,27 @@ export const mockPatchData: DataModel = {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
requestedActions: {},
requestedActions: {
'bitcoind/config': {
active: true,
request: {
packageId: 'bitcoind',
actionId: 'config',
severity: 'critical',
reason: 'LND likes BTC a certain way',
input: {
kind: 'partial',
value: {
color: '#ffffff',
rpcsettings: {
rpcuser: 'lnd',
},
testnet: false,
},
},
},
},
},
},
},
}

View File

@@ -101,17 +101,17 @@ export class DepErrorService {
}
}
// invalid config
// action required
if (
Object.values(pkg.requestedActions).some(
a =>
a.active &&
a.request.packageId === depId &&
a.request.actionId === 'config',
a.request.severity === 'critical',
)
) {
return {
type: 'configUnsatisfied',
type: 'actionRequired',
}
}

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