mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
[Feat] follow logs (#1714)
* tail logs * add cli * add FE * abstract http to shared * batch new logs * file download for logs * fix modal error when no config Co-authored-by: Chris Guida <chrisguida@users.noreply.github.com> Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> Co-authored-by: BluJ <mogulslayer@gmail.com>
This commit is contained in:
568
backend/Cargo.lock
generated
568
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,7 @@ regex = "1.6.0"
|
|||||||
reqwest = { version = "0.11.11", features = ["stream", "json", "socks"] }
|
reqwest = { version = "0.11.11", features = ["stream", "json", "socks"] }
|
||||||
reqwest_cookie_store = "0.3.0"
|
reqwest_cookie_store = "0.3.0"
|
||||||
rpassword = "6.0.1"
|
rpassword = "6.0.1"
|
||||||
rpc-toolkit = "0.2.0"
|
rpc-toolkit = "0.2.1"
|
||||||
rust-argon2 = "1.0.0"
|
rust-argon2 = "1.0.0"
|
||||||
scopeguard = "1.1" # because avahi-sys fucks your shit up
|
scopeguard = "1.1" # because avahi-sys fucks your shit up
|
||||||
serde = { version = "1.0.139", features = ["derive", "rc"] }
|
serde = { version = "1.0.139", features = ["derive", "rc"] }
|
||||||
@@ -131,7 +131,7 @@ thiserror = "1.0.31"
|
|||||||
tokio = { version = "1.19.2", features = ["full"] }
|
tokio = { version = "1.19.2", features = ["full"] }
|
||||||
tokio-stream = { version = "0.1.9", features = ["io-util", "sync"] }
|
tokio-stream = { version = "0.1.9", features = ["io-util", "sync"] }
|
||||||
tokio-tar = { git = "https://github.com/dr-bonez/tokio-tar.git" }
|
tokio-tar = { git = "https://github.com/dr-bonez/tokio-tar.git" }
|
||||||
tokio-tungstenite = "0.17.1"
|
tokio-tungstenite = { version = "0.17.1", features = ["native-tls"] }
|
||||||
tokio-util = { version = "0.7.3", features = ["io"] }
|
tokio-util = { version = "0.7.3", features = ["io"] }
|
||||||
torut = "0.2.1"
|
torut = "0.2.1"
|
||||||
tracing = "0.1.35"
|
tracing = "0.1.35"
|
||||||
|
|||||||
@@ -24,10 +24,14 @@ pub fn auth() -> Result<(), Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_metadata(_: &str, _: &ArgMatches) -> Result<Value, Error> {
|
pub fn cli_metadata() -> Value {
|
||||||
Ok(serde_json::json!({
|
serde_json::json!({
|
||||||
"platforms": ["cli"],
|
"platforms": ["cli"],
|
||||||
}))
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_metadata(_: &str, _: &ArgMatches) -> Result<Value, Error> {
|
||||||
|
Ok(cli_metadata())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -106,7 +110,7 @@ pub async fn login(
|
|||||||
#[arg] password: Option<String>,
|
#[arg] password: Option<String>,
|
||||||
#[arg(
|
#[arg(
|
||||||
parse(parse_metadata),
|
parse(parse_metadata),
|
||||||
default = "",
|
default = "cli_metadata",
|
||||||
help = "RPC Only: This value cannot be overidden from the cli"
|
help = "RPC Only: This value cannot be overidden from the cli"
|
||||||
)]
|
)]
|
||||||
metadata: Value,
|
metadata: Value,
|
||||||
|
|||||||
@@ -151,6 +151,33 @@ async fn inner_main(cfg_path: Option<&str>) -> Result<Option<Shutdown>, Error> {
|
|||||||
"/ws/db" => {
|
"/ws/db" => {
|
||||||
Ok(subscribe(ctx, req).await.unwrap_or_else(err_to_500))
|
Ok(subscribe(ctx, req).await.unwrap_or_else(err_to_500))
|
||||||
}
|
}
|
||||||
|
path if path.starts_with("/ws/rpc/") => {
|
||||||
|
match RequestGuid::from(
|
||||||
|
path.strip_prefix("/ws/rpc/").unwrap(),
|
||||||
|
) {
|
||||||
|
None => {
|
||||||
|
tracing::debug!("No Guid Path");
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::BAD_REQUEST)
|
||||||
|
.body(Body::empty())
|
||||||
|
}
|
||||||
|
Some(guid) => {
|
||||||
|
match ctx.get_ws_continuation_handler(&guid).await {
|
||||||
|
Some(cont) => match cont(req).await {
|
||||||
|
Ok(r) => Ok(r),
|
||||||
|
Err(e) => Response::builder()
|
||||||
|
.status(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
.body(Body::from(format!("{}", e))),
|
||||||
|
},
|
||||||
|
_ => Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::empty()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
path if path.starts_with("/rest/rpc/") => {
|
path if path.starts_with("/rest/rpc/") => {
|
||||||
match RequestGuid::from(
|
match RequestGuid::from(
|
||||||
path.strip_prefix("/rest/rpc/").unwrap(),
|
path.strip_prefix("/rest/rpc/").unwrap(),
|
||||||
@@ -162,16 +189,12 @@ async fn inner_main(cfg_path: Option<&str>) -> Result<Option<Shutdown>, Error> {
|
|||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
}
|
}
|
||||||
Some(guid) => {
|
Some(guid) => {
|
||||||
match ctx
|
match ctx.get_rest_continuation_handler(&guid).await
|
||||||
.rpc_stream_continuations
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.remove(&guid)
|
|
||||||
{
|
{
|
||||||
None => Response::builder()
|
None => Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
.body(Body::empty()),
|
.body(Body::empty()),
|
||||||
Some(cont) => match (cont.handler)(req).await {
|
Some(cont) => match cont(req).await {
|
||||||
Ok(r) => Ok(r),
|
Ok(r) => Ok(r),
|
||||||
Err(e) => Response::builder()
|
Err(e) => Response::builder()
|
||||||
.status(
|
.status(
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use tokio::process::Command;
|
|||||||
use tokio::sync::{broadcast, oneshot, Mutex, RwLock};
|
use tokio::sync::{broadcast, oneshot, Mutex, RwLock};
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::core::rpc_continuations::{RequestGuid, RpcContinuation};
|
use crate::core::rpc_continuations::{RequestGuid, RestHandler, RpcContinuation};
|
||||||
use crate::db::model::{Database, InstalledPackageDataEntry, PackageDataEntry};
|
use crate::db::model::{Database, InstalledPackageDataEntry, PackageDataEntry};
|
||||||
use crate::hostname::{derive_hostname, derive_id, get_product_key};
|
use crate::hostname::{derive_hostname, derive_id, get_product_key};
|
||||||
use crate::install::cleanup::{cleanup_failed, uninstall, CleanupFailedReceipts};
|
use crate::install::cleanup::{cleanup_failed, uninstall, CleanupFailedReceipts};
|
||||||
@@ -387,6 +387,58 @@ impl RpcContext {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub async fn clean_continuations(&self) {
|
||||||
|
let mut continuations = self.rpc_stream_continuations.lock().await;
|
||||||
|
let mut to_remove = Vec::new();
|
||||||
|
for (guid, cont) in &*continuations {
|
||||||
|
if cont.is_timed_out() {
|
||||||
|
to_remove.push(guid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for guid in to_remove {
|
||||||
|
continuations.remove(&guid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self, handler))]
|
||||||
|
pub async fn add_continuation(&self, guid: RequestGuid, handler: RpcContinuation) {
|
||||||
|
self.clean_continuations().await;
|
||||||
|
self.rpc_stream_continuations
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(guid, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_continuation_handler(&self, guid: &RequestGuid) -> Option<RestHandler> {
|
||||||
|
let mut continuations = self.rpc_stream_continuations.lock().await;
|
||||||
|
if let Some(cont) = continuations.remove(guid) {
|
||||||
|
cont.into_handler().await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_ws_continuation_handler(&self, guid: &RequestGuid) -> Option<RestHandler> {
|
||||||
|
let continuations = self.rpc_stream_continuations.lock().await;
|
||||||
|
if matches!(continuations.get(guid), Some(RpcContinuation::WebSocket(_))) {
|
||||||
|
drop(continuations);
|
||||||
|
self.get_continuation_handler(guid).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_rest_continuation_handler(&self, guid: &RequestGuid) -> Option<RestHandler> {
|
||||||
|
let continuations = self.rpc_stream_continuations.lock().await;
|
||||||
|
if matches!(continuations.get(guid), Some(RpcContinuation::Rest(_))) {
|
||||||
|
drop(continuations);
|
||||||
|
self.get_continuation_handler(guid).await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
impl Context for RpcContext {
|
impl Context for RpcContext {
|
||||||
fn host(&self) -> Host<&str> {
|
fn host(&self) -> Host<&str> {
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
use std::time::Instant;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures::future::BoxFuture;
|
use futures::future::BoxFuture;
|
||||||
use http::{Request, Response};
|
use futures::FutureExt;
|
||||||
use hyper::Body;
|
use helpers::TimedResource;
|
||||||
|
use hyper::upgrade::Upgraded;
|
||||||
|
use hyper::{Body, Error as HyperError, Request, Response};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
|
use tokio::task::JoinError;
|
||||||
|
use tokio_tungstenite::WebSocketStream;
|
||||||
|
|
||||||
|
use crate::{Error, ResultExt};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct RequestGuid<T: AsRef<str> = String>(T);
|
pub struct RequestGuid<T: AsRef<str> = String>(Arc<T>);
|
||||||
impl RequestGuid {
|
impl RequestGuid {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut buf = [0; 40];
|
let mut buf = [0; 40];
|
||||||
rand::thread_rng().fill_bytes(&mut buf);
|
rand::thread_rng().fill_bytes(&mut buf);
|
||||||
RequestGuid(base32::encode(
|
RequestGuid(Arc::new(base32::encode(
|
||||||
base32::Alphabet::RFC4648 { padding: false },
|
base32::Alphabet::RFC4648 { padding: false },
|
||||||
&buf,
|
&buf,
|
||||||
))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from(r: &str) -> Option<RequestGuid> {
|
pub fn from(r: &str) -> Option<RequestGuid> {
|
||||||
@@ -26,7 +33,7 @@ impl RequestGuid {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(RequestGuid(r.to_owned()))
|
Some(RequestGuid(Arc::new(r.to_owned())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
@@ -39,15 +46,71 @@ fn parse_guid() {
|
|||||||
|
|
||||||
impl<T: AsRef<str>> std::fmt::Display for RequestGuid<T> {
|
impl<T: AsRef<str>> std::fmt::Display for RequestGuid<T> {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
self.0.as_ref().fmt(f)
|
(&*self.0).as_ref().fmt(f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RpcContinuation {
|
pub type RestHandler = Box<
|
||||||
pub created_at: Instant,
|
dyn FnOnce(Request<Body>) -> BoxFuture<'static, Result<Response<Body>, crate::Error>> + Send,
|
||||||
pub handler: Box<
|
>;
|
||||||
dyn FnOnce(Request<Body>) -> BoxFuture<'static, Result<Response<Body>, crate::Error>>
|
|
||||||
+ Send
|
pub type WebSocketHandler = Box<
|
||||||
+ Sync,
|
dyn FnOnce(
|
||||||
>,
|
BoxFuture<'static, Result<Result<WebSocketStream<Upgraded>, HyperError>, JoinError>>,
|
||||||
|
) -> BoxFuture<'static, Result<(), Error>>
|
||||||
|
+ Send,
|
||||||
|
>;
|
||||||
|
|
||||||
|
pub enum RpcContinuation {
|
||||||
|
Rest(TimedResource<RestHandler>),
|
||||||
|
WebSocket(TimedResource<WebSocketHandler>),
|
||||||
|
}
|
||||||
|
impl RpcContinuation {
|
||||||
|
pub fn rest(handler: RestHandler, timeout: Duration) -> Self {
|
||||||
|
RpcContinuation::Rest(TimedResource::new(handler, timeout))
|
||||||
|
}
|
||||||
|
pub fn ws(handler: WebSocketHandler, timeout: Duration) -> Self {
|
||||||
|
RpcContinuation::WebSocket(TimedResource::new(handler, timeout))
|
||||||
|
}
|
||||||
|
pub fn is_timed_out(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
RpcContinuation::Rest(a) => a.is_timed_out(),
|
||||||
|
RpcContinuation::WebSocket(a) => a.is_timed_out(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn into_handler(self) -> Option<RestHandler> {
|
||||||
|
match self {
|
||||||
|
RpcContinuation::Rest(handler) => handler.get().await,
|
||||||
|
RpcContinuation::WebSocket(handler) => {
|
||||||
|
if let Some(handler) = handler.get().await {
|
||||||
|
Some(Box::new(
|
||||||
|
|req: Request<Body>| -> BoxFuture<'static, Result<Response<Body>, Error>> {
|
||||||
|
async move {
|
||||||
|
let (parts, body) = req.into_parts();
|
||||||
|
let req = Request::from_parts(parts, body);
|
||||||
|
let (res, ws_fut) = hyper_ws_listener::create_ws(req)
|
||||||
|
.with_kind(crate::ErrorKind::Network)?;
|
||||||
|
if let Some(ws_fut) = ws_fut {
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
match handler(ws_fut.boxed()).await {
|
||||||
|
Ok(()) => (),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("WebSocket Closed: {}", e);
|
||||||
|
tracing::debug!("{:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use rpc_toolkit::yajrc::RpcError;
|
|||||||
|
|
||||||
use crate::context::DiagnosticContext;
|
use crate::context::DiagnosticContext;
|
||||||
use crate::disk::repair;
|
use crate::disk::repair;
|
||||||
use crate::logs::{display_logs, fetch_logs, LogResponse, LogSource};
|
use crate::logs::{fetch_logs, LogResponse, LogSource};
|
||||||
use crate::shutdown::Shutdown;
|
use crate::shutdown::Shutdown;
|
||||||
use crate::util::display_none;
|
use crate::util::display_none;
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
@@ -23,19 +23,13 @@ pub fn error(#[context] ctx: DiagnosticContext) -> Result<Arc<RpcError>, Error>
|
|||||||
Ok(ctx.error.clone())
|
Ok(ctx.error.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command(display(display_logs))]
|
#[command(rpc_only)]
|
||||||
pub async fn logs(
|
pub async fn logs(
|
||||||
#[arg] limit: Option<usize>,
|
#[arg] limit: Option<usize>,
|
||||||
#[arg] cursor: Option<String>,
|
#[arg] cursor: Option<String>,
|
||||||
#[arg] before_flag: Option<bool>,
|
#[arg] before: bool,
|
||||||
) -> Result<LogResponse, Error> {
|
) -> Result<LogResponse, Error> {
|
||||||
Ok(fetch_logs(
|
Ok(fetch_logs(LogSource::Service(SYSTEMD_UNIT), limit, cursor, before).await?)
|
||||||
LogSource::Service(SYSTEMD_UNIT),
|
|
||||||
limit,
|
|
||||||
cursor,
|
|
||||||
before_flag.unwrap_or(false),
|
|
||||||
)
|
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command(display(display_none))]
|
#[command(display(display_none))]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
|
|
||||||
use color_eyre::eyre::eyre;
|
use color_eyre::eyre::eyre;
|
||||||
use emver::VersionRange;
|
use emver::VersionRange;
|
||||||
@@ -16,8 +16,8 @@ use http::{Request, Response, StatusCode};
|
|||||||
use hyper::Body;
|
use hyper::Body;
|
||||||
use patch_db::{DbHandle, LockReceipt, LockType};
|
use patch_db::{DbHandle, LockReceipt, LockType};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
|
use rpc_toolkit::command;
|
||||||
use rpc_toolkit::yajrc::RpcError;
|
use rpc_toolkit::yajrc::RpcError;
|
||||||
use rpc_toolkit::{command, Context};
|
|
||||||
use tokio::fs::{File, OpenOptions};
|
use tokio::fs::{File, OpenOptions};
|
||||||
use tokio::io::{AsyncRead, AsyncSeek, AsyncSeekExt};
|
use tokio::io::{AsyncRead, AsyncSeek, AsyncSeekExt};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
@@ -478,23 +478,11 @@ pub async fn sideload(
|
|||||||
}
|
}
|
||||||
.boxed()
|
.boxed()
|
||||||
});
|
});
|
||||||
let cont = RpcContinuation {
|
ctx.add_continuation(
|
||||||
created_at: Instant::now(), // TODO
|
guid.clone(),
|
||||||
handler,
|
RpcContinuation::rest(handler, Duration::from_secs(30)),
|
||||||
};
|
)
|
||||||
// gc the map
|
.await;
|
||||||
let mut guard = ctx.rpc_stream_continuations.lock().await;
|
|
||||||
let garbage_collected = std::mem::take(&mut *guard)
|
|
||||||
.into_iter()
|
|
||||||
.filter(|(_, v)| v.created_at.elapsed() < Duration::from_secs(30))
|
|
||||||
.collect::<BTreeMap<RequestGuid, RpcContinuation>>();
|
|
||||||
*guard = garbage_collected;
|
|
||||||
drop(guard);
|
|
||||||
// insert the new continuation
|
|
||||||
ctx.rpc_stream_continuations
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(guid.clone(), cont);
|
|
||||||
Ok(guid)
|
Ok(guid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,12 +525,7 @@ async fn cli_install(
|
|||||||
let body = Body::wrap_stream(tokio_util::io::ReaderStream::new(file));
|
let body = Body::wrap_stream(tokio_util::io::ReaderStream::new(file));
|
||||||
let res = ctx
|
let res = ctx
|
||||||
.client
|
.client
|
||||||
.post(format!(
|
.post(format!("{}/rest/rpc/{}", ctx.base_url, guid,))
|
||||||
"{}://{}/rest/rpc/{}",
|
|
||||||
ctx.protocol(),
|
|
||||||
ctx.host(),
|
|
||||||
guid
|
|
||||||
))
|
|
||||||
.header(CONTENT_LENGTH, content_length)
|
.header(CONTENT_LENGTH, content_length)
|
||||||
.body(body)
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
|
|||||||
@@ -1,22 +1,117 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::ops::Deref;
|
||||||
|
use std::ops::DerefMut;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::time::{Duration, UNIX_EPOCH};
|
use std::time::{Duration, UNIX_EPOCH};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use clap::ArgMatches;
|
|
||||||
use color_eyre::eyre::eyre;
|
use color_eyre::eyre::eyre;
|
||||||
use futures::TryStreamExt;
|
use futures::stream::BoxStream;
|
||||||
|
use futures::Stream;
|
||||||
|
use futures::{FutureExt, SinkExt, StreamExt, TryStreamExt};
|
||||||
|
use hyper::upgrade::Upgraded;
|
||||||
|
use hyper::Error as HyperError;
|
||||||
use rpc_toolkit::command;
|
use rpc_toolkit::command;
|
||||||
|
use rpc_toolkit::yajrc::RpcError;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::{Child, Command};
|
||||||
|
use tokio::task::JoinError;
|
||||||
use tokio_stream::wrappers::LinesStream;
|
use tokio_stream::wrappers::LinesStream;
|
||||||
|
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
|
||||||
|
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
use tokio_tungstenite::WebSocketStream;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
|
use crate::context::{CliContext, RpcContext};
|
||||||
|
use crate::core::rpc_continuations::{RequestGuid, RpcContinuation};
|
||||||
use crate::error::ResultExt;
|
use crate::error::ResultExt;
|
||||||
use crate::procedure::docker::DockerProcedure;
|
use crate::procedure::docker::DockerProcedure;
|
||||||
use crate::s9pk::manifest::PackageId;
|
use crate::s9pk::manifest::PackageId;
|
||||||
use crate::util::serde::Reversible;
|
use crate::util::{display_none, serde::Reversible};
|
||||||
use crate::Error;
|
use crate::{Error, ErrorKind};
|
||||||
|
|
||||||
|
#[pin_project::pin_project]
|
||||||
|
struct LogStream {
|
||||||
|
_child: Child,
|
||||||
|
#[pin]
|
||||||
|
entries: BoxStream<'static, Result<JournalctlEntry, Error>>,
|
||||||
|
}
|
||||||
|
impl Deref for LogStream {
|
||||||
|
type Target = BoxStream<'static, Result<JournalctlEntry, Error>>;
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl DerefMut for LogStream {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Stream for LogStream {
|
||||||
|
type Item = Result<JournalctlEntry, Error>;
|
||||||
|
fn poll_next(
|
||||||
|
self: std::pin::Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
) -> std::task::Poll<Option<Self::Item>> {
|
||||||
|
let this = self.project();
|
||||||
|
Stream::poll_next(this.entries, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||||
|
self.entries.size_hint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(logs, ws_fut))]
|
||||||
|
async fn ws_handler<
|
||||||
|
WSFut: Future<Output = Result<Result<WebSocketStream<Upgraded>, HyperError>, JoinError>>,
|
||||||
|
>(
|
||||||
|
first_entry: Option<LogEntry>,
|
||||||
|
mut logs: LogStream,
|
||||||
|
ws_fut: WSFut,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let mut stream = ws_fut
|
||||||
|
.await
|
||||||
|
.with_kind(crate::ErrorKind::Network)?
|
||||||
|
.with_kind(crate::ErrorKind::Unknown)?;
|
||||||
|
|
||||||
|
if let Some(first_entry) = first_entry {
|
||||||
|
stream
|
||||||
|
.send(Message::Text(
|
||||||
|
serde_json::to_string(&first_entry).with_kind(ErrorKind::Serialization)?,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.with_kind(ErrorKind::Network)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(entry) = tokio::select! {
|
||||||
|
a = logs.try_next() => Some(a?),
|
||||||
|
a = stream.try_next() => { a.with_kind(crate::ErrorKind::Network)?; None }
|
||||||
|
} {
|
||||||
|
if let Some(entry) = entry {
|
||||||
|
let (_, log_entry) = entry.log_entry()?;
|
||||||
|
stream
|
||||||
|
.send(Message::Text(
|
||||||
|
serde_json::to_string(&log_entry).with_kind(ErrorKind::Serialization)?,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.with_kind(ErrorKind::Network)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stream
|
||||||
|
.close(Some(CloseFrame {
|
||||||
|
code: CloseCode::Normal,
|
||||||
|
reason: "Log Stream Finished".into(),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.with_kind(ErrorKind::Network)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
@@ -25,6 +120,12 @@ pub struct LogResponse {
|
|||||||
start_cursor: Option<String>,
|
start_cursor: Option<String>,
|
||||||
end_cursor: Option<String>,
|
end_cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||||
|
#[serde(rename_all = "kebab-case", tag = "type")]
|
||||||
|
pub struct LogFollowResponse {
|
||||||
|
start_cursor: Option<String>,
|
||||||
|
guid: RequestGuid,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||||
pub struct LogEntry {
|
pub struct LogEntry {
|
||||||
@@ -111,38 +212,145 @@ pub enum LogSource {
|
|||||||
Container(PackageId),
|
Container(PackageId),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn display_logs(all: LogResponse, _: &ArgMatches) {
|
#[command(
|
||||||
for entry in all.entries.iter() {
|
custom_cli(cli_logs(async, context(CliContext))),
|
||||||
println!("{}", entry);
|
subcommands(self(logs_nofollow(async)), logs_follow),
|
||||||
}
|
display(display_none)
|
||||||
}
|
)]
|
||||||
|
|
||||||
#[command(display(display_logs))]
|
|
||||||
pub async fn logs(
|
pub async fn logs(
|
||||||
#[arg] id: PackageId,
|
#[arg] id: PackageId,
|
||||||
#[arg] limit: Option<usize>,
|
#[arg(short = 'l', long = "limit")] limit: Option<usize>,
|
||||||
#[arg] cursor: Option<String>,
|
#[arg(short = 'c', long = "cursor")] cursor: Option<String>,
|
||||||
#[arg] before_flag: Option<bool>,
|
#[arg(short = 'B', long = "before", default)] before: bool,
|
||||||
|
#[arg(short = 'f', long = "follow", default)] follow: bool,
|
||||||
|
) -> Result<(PackageId, Option<usize>, Option<String>, bool, bool), Error> {
|
||||||
|
Ok((id, limit, cursor, before, follow))
|
||||||
|
}
|
||||||
|
pub async fn cli_logs(
|
||||||
|
ctx: CliContext,
|
||||||
|
(id, limit, cursor, before, follow): (PackageId, Option<usize>, Option<String>, bool, bool),
|
||||||
|
) -> Result<(), RpcError> {
|
||||||
|
if follow {
|
||||||
|
if cursor.is_some() {
|
||||||
|
return Err(RpcError::from(Error::new(
|
||||||
|
eyre!("The argument '--cursor <cursor>' cannot be used with '--follow'"),
|
||||||
|
crate::ErrorKind::InvalidRequest,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if before {
|
||||||
|
return Err(RpcError::from(Error::new(
|
||||||
|
eyre!("The argument '--before' cannot be used with '--follow'"),
|
||||||
|
crate::ErrorKind::InvalidRequest,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
cli_logs_generic_follow(ctx, "package.logs.follow", Some(id), limit).await
|
||||||
|
} else {
|
||||||
|
cli_logs_generic_nofollow(ctx, "package.logs", Some(id), limit, cursor, before).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn logs_nofollow(
|
||||||
|
_ctx: (),
|
||||||
|
(id, limit, cursor, before, _): (PackageId, Option<usize>, Option<String>, bool, bool),
|
||||||
) -> Result<LogResponse, Error> {
|
) -> Result<LogResponse, Error> {
|
||||||
Ok(fetch_logs(
|
fetch_logs(LogSource::Container(id), limit, cursor, before).await
|
||||||
LogSource::Container(id),
|
}
|
||||||
limit,
|
#[command(rpc_only, rename = "follow", display(display_none))]
|
||||||
cursor,
|
pub async fn logs_follow(
|
||||||
before_flag.unwrap_or(false),
|
#[context] ctx: RpcContext,
|
||||||
)
|
#[parent_data] (id, limit, _, _, _): (PackageId, Option<usize>, Option<String>, bool, bool),
|
||||||
.await?)
|
) -> Result<LogFollowResponse, Error> {
|
||||||
|
follow_logs(ctx, LogSource::Container(id), limit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument]
|
pub async fn cli_logs_generic_nofollow(
|
||||||
pub async fn fetch_logs(
|
ctx: CliContext,
|
||||||
id: LogSource,
|
method: &str,
|
||||||
|
id: Option<PackageId>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
cursor: Option<String>,
|
cursor: Option<String>,
|
||||||
before_flag: bool,
|
before: bool,
|
||||||
) -> Result<LogResponse, Error> {
|
) -> Result<(), RpcError> {
|
||||||
let mut cmd = Command::new("journalctl");
|
let res = rpc_toolkit::command_helpers::call_remote(
|
||||||
|
ctx.clone(),
|
||||||
|
method,
|
||||||
|
serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"limit": limit,
|
||||||
|
"cursor": cursor,
|
||||||
|
"before": before,
|
||||||
|
}),
|
||||||
|
PhantomData::<LogResponse>,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.result?;
|
||||||
|
|
||||||
let limit = limit.unwrap_or(50);
|
for entry in res.entries.iter() {
|
||||||
|
println!("{}", entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cli_logs_generic_follow(
|
||||||
|
ctx: CliContext,
|
||||||
|
method: &str,
|
||||||
|
id: Option<PackageId>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<(), RpcError> {
|
||||||
|
let res = rpc_toolkit::command_helpers::call_remote(
|
||||||
|
ctx.clone(),
|
||||||
|
method,
|
||||||
|
serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"limit": limit,
|
||||||
|
}),
|
||||||
|
PhantomData::<LogFollowResponse>,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.result?;
|
||||||
|
|
||||||
|
let mut base_url = ctx.base_url.clone();
|
||||||
|
let ws_scheme = match base_url.scheme() {
|
||||||
|
"https" => "wss",
|
||||||
|
"http" => "ws",
|
||||||
|
_ => {
|
||||||
|
return Err(Error::new(
|
||||||
|
eyre!("Cannot parse scheme from base URL"),
|
||||||
|
crate::ErrorKind::ParseUrl,
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
base_url.set_scheme(ws_scheme).or_else(|_| {
|
||||||
|
Err(Error::new(
|
||||||
|
eyre!("Cannot set URL scheme"),
|
||||||
|
crate::ErrorKind::ParseUrl,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let (mut stream, _) =
|
||||||
|
// base_url is "http://127.0.0.1/", with a trailing slash, so we don't put a leading slash in this path:
|
||||||
|
tokio_tungstenite::connect_async(format!("{}ws/rpc/{}", base_url, res.guid)).await?;
|
||||||
|
while let Some(log) = stream.try_next().await? {
|
||||||
|
match log {
|
||||||
|
Message::Text(log) => {
|
||||||
|
println!("{}", serde_json::from_str::<LogEntry>(&log)?);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn journalctl(
|
||||||
|
id: LogSource,
|
||||||
|
limit: usize,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
before: bool,
|
||||||
|
follow: bool,
|
||||||
|
) -> Result<LogStream, Error> {
|
||||||
|
let mut cmd = Command::new("journalctl");
|
||||||
|
cmd.kill_on_drop(true);
|
||||||
|
|
||||||
cmd.arg("--output=json");
|
cmd.arg("--output=json");
|
||||||
cmd.arg("--output-fields=MESSAGE");
|
cmd.arg("--output-fields=MESSAGE");
|
||||||
@@ -163,16 +371,15 @@ pub async fn fetch_logs(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let cursor_formatted = format!("--after-cursor={}", cursor.clone().unwrap_or("".to_owned()));
|
let cursor_formatted = format!("--after-cursor={}", cursor.clone().unwrap_or(""));
|
||||||
let mut get_prev_logs_and_reverse = false;
|
|
||||||
if cursor.is_some() {
|
if cursor.is_some() {
|
||||||
cmd.arg(&cursor_formatted);
|
cmd.arg(&cursor_formatted);
|
||||||
if before_flag {
|
if before {
|
||||||
get_prev_logs_and_reverse = true;
|
cmd.arg("--reverse");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if get_prev_logs_and_reverse {
|
if follow {
|
||||||
cmd.arg("--reverse");
|
cmd.arg("--follow");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut child = cmd.stdout(Stdio::piped()).spawn()?;
|
let mut child = cmd.stdout(Stdio::piped()).spawn()?;
|
||||||
@@ -185,7 +392,7 @@ pub async fn fetch_logs(
|
|||||||
|
|
||||||
let journalctl_entries = LinesStream::new(out.lines());
|
let journalctl_entries = LinesStream::new(out.lines());
|
||||||
|
|
||||||
let mut deserialized_entries = journalctl_entries
|
let deserialized_entries = journalctl_entries
|
||||||
.map_err(|e| Error::new(e, crate::ErrorKind::Journald))
|
.map_err(|e| Error::new(e, crate::ErrorKind::Journald))
|
||||||
.and_then(|s| {
|
.and_then(|s| {
|
||||||
futures::future::ready(
|
futures::future::ready(
|
||||||
@@ -194,16 +401,37 @@ pub async fn fetch_logs(
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Ok(LogStream {
|
||||||
|
_child: child,
|
||||||
|
entries: deserialized_entries.boxed(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
pub async fn fetch_logs(
|
||||||
|
id: LogSource,
|
||||||
|
limit: Option<usize>,
|
||||||
|
cursor: Option<String>,
|
||||||
|
before: bool,
|
||||||
|
) -> Result<LogResponse, Error> {
|
||||||
|
let limit = limit.unwrap_or(50);
|
||||||
|
let mut stream = journalctl(id, limit, cursor.as_deref(), before, false).await?;
|
||||||
|
|
||||||
let mut entries = Vec::with_capacity(limit);
|
let mut entries = Vec::with_capacity(limit);
|
||||||
let mut start_cursor = None;
|
let mut start_cursor = None;
|
||||||
|
|
||||||
if let Some(first) = deserialized_entries.try_next().await? {
|
if let Some(first) = tokio::time::timeout(Duration::from_secs(1), stream.try_next())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.transpose()?
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
let (cursor, entry) = first.log_entry()?;
|
let (cursor, entry) = first.log_entry()?;
|
||||||
start_cursor = Some(cursor);
|
start_cursor = Some(cursor);
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mut end_cursor, entries) = deserialized_entries
|
let (mut end_cursor, entries) = stream
|
||||||
.try_fold(
|
.try_fold(
|
||||||
(start_cursor.clone(), entries),
|
(start_cursor.clone(), entries),
|
||||||
|(_, mut acc), entry| async move {
|
|(_, mut acc), entry| async move {
|
||||||
@@ -215,7 +443,7 @@ pub async fn fetch_logs(
|
|||||||
.await?;
|
.await?;
|
||||||
let mut entries = Reversible::new(entries);
|
let mut entries = Reversible::new(entries);
|
||||||
// reverse again so output is always in increasing chronological order
|
// reverse again so output is always in increasing chronological order
|
||||||
if get_prev_logs_and_reverse {
|
if cursor.is_some() && before {
|
||||||
entries.reverse();
|
entries.reverse();
|
||||||
std::mem::swap(&mut start_cursor, &mut end_cursor);
|
std::mem::swap(&mut start_cursor, &mut end_cursor);
|
||||||
}
|
}
|
||||||
@@ -226,21 +454,81 @@ pub async fn fetch_logs(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(ctx))]
|
||||||
|
pub async fn follow_logs(
|
||||||
|
ctx: RpcContext,
|
||||||
|
id: LogSource,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<LogFollowResponse, Error> {
|
||||||
|
let limit = limit.unwrap_or(50);
|
||||||
|
let mut stream = journalctl(id, limit, None, false, true).await?;
|
||||||
|
|
||||||
|
let mut start_cursor = None;
|
||||||
|
let mut first_entry = None;
|
||||||
|
|
||||||
|
if let Some(first) = tokio::time::timeout(Duration::from_secs(1), stream.try_next())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.transpose()?
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let (cursor, entry) = first.log_entry()?;
|
||||||
|
start_cursor = Some(cursor);
|
||||||
|
first_entry = Some(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
let guid = RequestGuid::new();
|
||||||
|
ctx.add_continuation(
|
||||||
|
guid.clone(),
|
||||||
|
RpcContinuation::ws(
|
||||||
|
Box::new(move |ws_fut| ws_handler(first_entry, stream, ws_fut).boxed()),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(LogFollowResponse { start_cursor, guid })
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[tokio::test]
|
||||||
|
// pub async fn test_logs() {
|
||||||
|
// let response = fetch_logs(
|
||||||
|
// // change `tor.service` to an actual journald unit on your machine
|
||||||
|
// // LogSource::Service("tor.service"),
|
||||||
|
// // first run `docker run --name=hello-world.embassy --log-driver=journald hello-world`
|
||||||
|
// LogSource::Container("hello-world".parse().unwrap()),
|
||||||
|
// // Some(5),
|
||||||
|
// None,
|
||||||
|
// None,
|
||||||
|
// // Some("s=1b8c418e28534400856c27b211dd94fd;i=5a7;b=97571c13a1284f87bc0639b5cff5acbe;m=740e916;t=5ca073eea3445;x=f45bc233ca328348".to_owned()),
|
||||||
|
// false,
|
||||||
|
// true,
|
||||||
|
// )
|
||||||
|
// .await
|
||||||
|
// .unwrap();
|
||||||
|
// let serialized = serde_json::to_string_pretty(&response).unwrap();
|
||||||
|
// println!("{}", serialized);
|
||||||
|
// }
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
pub async fn test_logs() {
|
pub async fn test_logs() {
|
||||||
let response = fetch_logs(
|
let mut cmd = Command::new("journalctl");
|
||||||
// change `tor.service` to an actual journald unit on your machine
|
cmd.kill_on_drop(true);
|
||||||
// LogSource::Service("tor.service"),
|
|
||||||
// first run `docker run --name=hello-world.embassy --log-driver=journald hello-world`
|
cmd.arg("-f");
|
||||||
LogSource::Container("hello-world".parse().unwrap()),
|
cmd.arg("CONTAINER_NAME=hello-world.embassy");
|
||||||
// Some(5),
|
|
||||||
None,
|
let mut child = cmd.stdout(Stdio::piped()).spawn().unwrap();
|
||||||
None,
|
let out = BufReader::new(
|
||||||
// Some("s=1b8c418e28534400856c27b211dd94fd;i=5a7;b=97571c13a1284f87bc0639b5cff5acbe;m=740e916;t=5ca073eea3445;x=f45bc233ca328348".to_owned()),
|
child
|
||||||
false,
|
.stdout
|
||||||
)
|
.take()
|
||||||
.await
|
.ok_or_else(|| Error::new(eyre!("No stdout available"), crate::ErrorKind::Journald))
|
||||||
.unwrap();
|
.unwrap(),
|
||||||
let serialized = serde_json::to_string_pretty(&response).unwrap();
|
);
|
||||||
println!("{}", serialized);
|
|
||||||
|
let mut journalctl_entries = LinesStream::new(out.lines());
|
||||||
|
|
||||||
|
while let Some(line) = journalctl_entries.try_next().await.unwrap() {
|
||||||
|
dbg!(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,126 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
use color_eyre::eyre::eyre;
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
use rpc_toolkit::command;
|
use rpc_toolkit::command;
|
||||||
|
use rpc_toolkit::yajrc::RpcError;
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use tokio::sync::broadcast::Receiver;
|
use tokio::sync::broadcast::Receiver;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::context::RpcContext;
|
use crate::context::{CliContext, RpcContext};
|
||||||
use crate::disk::util::{get_available, get_used};
|
use crate::disk::util::{get_available, get_used};
|
||||||
use crate::logs::{display_logs, fetch_logs, LogResponse, LogSource};
|
use crate::logs::{
|
||||||
|
cli_logs_generic_follow, cli_logs_generic_nofollow, fetch_logs, follow_logs, LogFollowResponse,
|
||||||
|
LogResponse, LogSource,
|
||||||
|
};
|
||||||
use crate::shutdown::Shutdown;
|
use crate::shutdown::Shutdown;
|
||||||
|
use crate::util::display_none;
|
||||||
use crate::util::serde::{display_serializable, IoFormat};
|
use crate::util::serde::{display_serializable, IoFormat};
|
||||||
use crate::{Error, ErrorKind};
|
use crate::{Error, ErrorKind};
|
||||||
|
|
||||||
pub const SYSTEMD_UNIT: &'static str = "embassyd";
|
pub const SYSTEMD_UNIT: &'static str = "embassyd";
|
||||||
|
|
||||||
#[command(display(display_logs))]
|
#[command(
|
||||||
|
custom_cli(cli_logs(async, context(CliContext))),
|
||||||
|
subcommands(self(logs_nofollow(async)), logs_follow),
|
||||||
|
display(display_none)
|
||||||
|
)]
|
||||||
pub async fn logs(
|
pub async fn logs(
|
||||||
#[arg] limit: Option<usize>,
|
#[arg(short = 'l', long = "limit")] limit: Option<usize>,
|
||||||
#[arg] cursor: Option<String>,
|
#[arg(short = 'c', long = "cursor")] cursor: Option<String>,
|
||||||
#[arg] before_flag: Option<bool>,
|
#[arg(short = 'B', long = "before", default)] before: bool,
|
||||||
|
#[arg(short = 'f', long = "follow", default)] follow: bool,
|
||||||
|
) -> Result<(Option<usize>, Option<String>, bool, bool), Error> {
|
||||||
|
Ok((limit, cursor, before, follow))
|
||||||
|
}
|
||||||
|
pub async fn cli_logs(
|
||||||
|
ctx: CliContext,
|
||||||
|
(limit, cursor, before, follow): (Option<usize>, Option<String>, bool, bool),
|
||||||
|
) -> Result<(), RpcError> {
|
||||||
|
if follow {
|
||||||
|
if cursor.is_some() {
|
||||||
|
return Err(RpcError::from(Error::new(
|
||||||
|
eyre!("The argument '--cursor <cursor>' cannot be used with '--follow'"),
|
||||||
|
crate::ErrorKind::InvalidRequest,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if before {
|
||||||
|
return Err(RpcError::from(Error::new(
|
||||||
|
eyre!("The argument '--before' cannot be used with '--follow'"),
|
||||||
|
crate::ErrorKind::InvalidRequest,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
cli_logs_generic_follow(ctx, "server.logs.follow", None, limit).await
|
||||||
|
} else {
|
||||||
|
cli_logs_generic_nofollow(ctx, "server.logs", None, limit, cursor, before).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn logs_nofollow(
|
||||||
|
_ctx: (),
|
||||||
|
(limit, cursor, before, _): (Option<usize>, Option<String>, bool, bool),
|
||||||
) -> Result<LogResponse, Error> {
|
) -> Result<LogResponse, Error> {
|
||||||
Ok(fetch_logs(
|
fetch_logs(LogSource::Service(SYSTEMD_UNIT), limit, cursor, before).await
|
||||||
LogSource::Service(SYSTEMD_UNIT),
|
|
||||||
limit,
|
|
||||||
cursor,
|
|
||||||
before_flag.unwrap_or(false),
|
|
||||||
)
|
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command(rename = "kernel-logs", display(display_logs))]
|
#[command(rpc_only, rename = "follow", display(display_none))]
|
||||||
|
pub async fn logs_follow(
|
||||||
|
#[context] ctx: RpcContext,
|
||||||
|
#[parent_data] (limit, _, _, _): (Option<usize>, Option<String>, bool, bool),
|
||||||
|
) -> Result<LogFollowResponse, Error> {
|
||||||
|
follow_logs(ctx, LogSource::Service(SYSTEMD_UNIT), limit).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command(
|
||||||
|
rename = "kernel-logs",
|
||||||
|
custom_cli(cli_kernel_logs(async, context(CliContext))),
|
||||||
|
subcommands(self(kernel_logs_nofollow(async)), kernel_logs_follow),
|
||||||
|
display(display_none)
|
||||||
|
)]
|
||||||
pub async fn kernel_logs(
|
pub async fn kernel_logs(
|
||||||
#[arg] limit: Option<usize>,
|
#[arg(short = 'l', long = "limit")] limit: Option<usize>,
|
||||||
#[arg] cursor: Option<String>,
|
#[arg(short = 'c', long = "cursor")] cursor: Option<String>,
|
||||||
#[arg] before_flag: Option<bool>,
|
#[arg(short = 'B', long = "before", default)] before: bool,
|
||||||
|
#[arg(short = 'f', long = "follow", default)] follow: bool,
|
||||||
|
) -> Result<(Option<usize>, Option<String>, bool, bool), Error> {
|
||||||
|
Ok((limit, cursor, before, follow))
|
||||||
|
}
|
||||||
|
pub async fn cli_kernel_logs(
|
||||||
|
ctx: CliContext,
|
||||||
|
(limit, cursor, before, follow): (Option<usize>, Option<String>, bool, bool),
|
||||||
|
) -> Result<(), RpcError> {
|
||||||
|
if follow {
|
||||||
|
if cursor.is_some() {
|
||||||
|
return Err(RpcError::from(Error::new(
|
||||||
|
eyre!("The argument '--cursor <cursor>' cannot be used with '--follow'"),
|
||||||
|
crate::ErrorKind::InvalidRequest,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if before {
|
||||||
|
return Err(RpcError::from(Error::new(
|
||||||
|
eyre!("The argument '--before' cannot be used with '--follow'"),
|
||||||
|
crate::ErrorKind::InvalidRequest,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
cli_logs_generic_follow(ctx, "server.kernel-logs.follow", None, limit).await
|
||||||
|
} else {
|
||||||
|
cli_logs_generic_nofollow(ctx, "server.kernel-logs", None, limit, cursor, before).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn kernel_logs_nofollow(
|
||||||
|
_ctx: (),
|
||||||
|
(limit, cursor, before, _): (Option<usize>, Option<String>, bool, bool),
|
||||||
) -> Result<LogResponse, Error> {
|
) -> Result<LogResponse, Error> {
|
||||||
Ok(fetch_logs(
|
fetch_logs(LogSource::Service(SYSTEMD_UNIT), limit, cursor, before).await
|
||||||
LogSource::Kernel,
|
}
|
||||||
limit,
|
|
||||||
cursor,
|
#[command(rpc_only, rename = "follow", display(display_none))]
|
||||||
before_flag.unwrap_or(false),
|
pub async fn kernel_logs_follow(
|
||||||
)
|
#[context] ctx: RpcContext,
|
||||||
.await?)
|
#[parent_data] (limit, _, _, _): (Option<usize>, Option<String>, bool, bool),
|
||||||
|
) -> Result<LogFollowResponse, Error> {
|
||||||
|
follow_logs(ctx, LogSource::Service(SYSTEMD_UNIT), limit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -427,7 +427,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"schematicCollections": ["@ionic/angular-toolkit"]
|
"schematicCollections": ["@ionic/angular-toolkit"],
|
||||||
|
"analytics": false
|
||||||
},
|
},
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@ionic/angular-toolkit:component": {
|
"@ionic/angular-toolkit:component": {
|
||||||
|
|||||||
@@ -7,41 +7,51 @@
|
|||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
||||||
<ion-content
|
<ion-content
|
||||||
[scrollEvents]="true"
|
[scrollEvents]="true"
|
||||||
(ionScroll)="scrollEvent()"
|
(ionScrollEnd)="scrollEnd()"
|
||||||
style="height: 100%;"
|
|
||||||
id="ion-content"
|
|
||||||
class="ion-padding"
|
class="ion-padding"
|
||||||
>
|
>
|
||||||
<ion-infinite-scroll id="scroller" *ngIf="!loading && needInfinite" position="top" threshold="0" (ionInfinite)="loadData($event)">
|
<ion-infinite-scroll
|
||||||
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
|
id="scroller"
|
||||||
|
*ngIf="!loading && needInfinite"
|
||||||
|
position="top"
|
||||||
|
threshold="0"
|
||||||
|
(ionInfinite)="doInfinite($event)"
|
||||||
|
>
|
||||||
|
<ion-infinite-scroll-content
|
||||||
|
loadingSpinner="lines"
|
||||||
|
></ion-infinite-scroll-content>
|
||||||
</ion-infinite-scroll>
|
</ion-infinite-scroll>
|
||||||
|
|
||||||
<div id="container">
|
<div id="container">
|
||||||
<div id="template" style="white-space: pre-line;"></div>
|
<div id="template" style="white-space: pre-line"></div>
|
||||||
</div>
|
|
||||||
<div id="button-div" *ngIf="!loading" style="width: 100%; text-align: center;">
|
|
||||||
<ion-button *ngIf="!loadingMore" (click)="loadMore()" strong color="dark">
|
|
||||||
Load More
|
|
||||||
<ion-icon slot="end" name="refresh"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
<ion-spinner *ngIf="loadingMore" name="lines" color="warning"></ion-spinner>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="bottom-div"></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
*ngIf="!loading"
|
|
||||||
[ngStyle]="{
|
[ngStyle]="{
|
||||||
'position': 'fixed',
|
'position': 'fixed',
|
||||||
'bottom': '50px',
|
'bottom': '50px',
|
||||||
'right': isOnBottom ? '-52px' : '30px',
|
'right': isOnBottom ? '-52px' : '30px',
|
||||||
'border-radius': '100%',
|
'border-radius': '100%',
|
||||||
'transition': 'right 0.4s ease-out'
|
'transition': 'right 0.25s ease-out'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<ion-button style="width: 50px; height: 50px; --padding-start: 0px; --padding-end: 0px; --border-radius: 100%;" color="dark" (click)="scrollToBottom()" strong>
|
<ion-button
|
||||||
|
style="
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
--padding-start: 0px;
|
||||||
|
--padding-end: 0px;
|
||||||
|
--border-radius: 100%;
|
||||||
|
"
|
||||||
|
color="dark"
|
||||||
|
(click)="scrollToBottom()"
|
||||||
|
strong
|
||||||
|
>
|
||||||
<ion-icon name="chevron-down"></ion-icon>
|
<ion-icon name="chevron-down"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Component, ViewChild } from '@angular/core'
|
import { Component, ViewChild } from '@angular/core'
|
||||||
import { IonContent } from '@ionic/angular'
|
import { IonContent } from '@ionic/angular'
|
||||||
import { ApiService } from 'src/app/services/api/api.service'
|
import { ApiService } from 'src/app/services/api/api.service'
|
||||||
import { toLocalIsoString } from '@start9labs/shared'
|
import { ErrorToastService, toLocalIsoString } from '@start9labs/shared'
|
||||||
|
|
||||||
var Convert = require('ansi-to-html')
|
var Convert = require('ansi-to-html')
|
||||||
var convert = new Convert({
|
var convert = new Convert({
|
||||||
bg: 'transparent',
|
bg: 'transparent',
|
||||||
@@ -15,122 +16,80 @@ var convert = new Convert({
|
|||||||
export class LogsPage {
|
export class LogsPage {
|
||||||
@ViewChild(IonContent) private content?: IonContent
|
@ViewChild(IonContent) private content?: IonContent
|
||||||
loading = true
|
loading = true
|
||||||
loadingMore = false
|
|
||||||
needInfinite = true
|
needInfinite = true
|
||||||
startCursor?: string
|
startCursor?: string
|
||||||
endCursor?: string
|
|
||||||
limit = 200
|
limit = 200
|
||||||
isOnBottom = true
|
isOnBottom = true
|
||||||
|
|
||||||
constructor(private readonly api: ApiService) {}
|
constructor(
|
||||||
|
private readonly api: ApiService,
|
||||||
|
private readonly errToast: ErrorToastService,
|
||||||
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
async ngOnInit() {
|
||||||
this.getLogs()
|
await this.getLogs()
|
||||||
|
this.loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLogs() {
|
scrollEnd() {
|
||||||
try {
|
const bottomDiv = document.getElementById('bottom-div')
|
||||||
// get logs
|
|
||||||
const logs = await this.fetch()
|
|
||||||
|
|
||||||
if (!logs?.length) return
|
|
||||||
|
|
||||||
const container = document.getElementById('container')
|
|
||||||
const beforeContainerHeight = container?.scrollHeight || 0
|
|
||||||
const newLogs = document.getElementById('template')?.cloneNode(true)
|
|
||||||
|
|
||||||
if (!(newLogs instanceof HTMLElement)) return
|
|
||||||
|
|
||||||
newLogs.innerHTML =
|
|
||||||
logs
|
|
||||||
.map(
|
|
||||||
l =>
|
|
||||||
`<b>${toLocalIsoString(
|
|
||||||
new Date(l.timestamp),
|
|
||||||
)}</b> ${convert.toHtml(l.message)}`,
|
|
||||||
)
|
|
||||||
.join('\n') + (logs.length ? '\n' : '')
|
|
||||||
container?.prepend(newLogs)
|
|
||||||
|
|
||||||
const afterContainerHeight = container?.scrollHeight || 0
|
|
||||||
|
|
||||||
// scroll down
|
|
||||||
scrollBy(0, afterContainerHeight - beforeContainerHeight)
|
|
||||||
this.content?.scrollToPoint(
|
|
||||||
0,
|
|
||||||
afterContainerHeight - beforeContainerHeight,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (logs.length < this.limit) {
|
|
||||||
this.needInfinite = false
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetch(isBefore: boolean = true) {
|
|
||||||
try {
|
|
||||||
const cursor = isBefore ? this.startCursor : this.endCursor
|
|
||||||
|
|
||||||
const logsRes = await this.api.getLogs({
|
|
||||||
cursor,
|
|
||||||
before_flag: !!cursor ? isBefore : undefined,
|
|
||||||
limit: this.limit,
|
|
||||||
})
|
|
||||||
|
|
||||||
if ((isBefore || this.startCursor) && logsRes['start-cursor']) {
|
|
||||||
this.startCursor = logsRes['start-cursor']
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((!isBefore || !this.endCursor) && logsRes['end-cursor']) {
|
|
||||||
this.endCursor = logsRes['end-cursor']
|
|
||||||
}
|
|
||||||
this.loading = false
|
|
||||||
|
|
||||||
return logsRes.entries
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadMore() {
|
|
||||||
try {
|
|
||||||
this.loadingMore = true
|
|
||||||
const logs = await this.fetch(false)
|
|
||||||
|
|
||||||
if (!logs?.length) return (this.loadingMore = false)
|
|
||||||
|
|
||||||
const container = document.getElementById('container')
|
|
||||||
const newLogs = document.getElementById('template')?.cloneNode(true)
|
|
||||||
|
|
||||||
if (!(newLogs instanceof HTMLElement)) return
|
|
||||||
|
|
||||||
newLogs.innerHTML =
|
|
||||||
logs
|
|
||||||
.map(
|
|
||||||
l =>
|
|
||||||
`<b>${toLocalIsoString(
|
|
||||||
new Date(l.timestamp),
|
|
||||||
)}</b> ${convert.toHtml(l.message)}`,
|
|
||||||
)
|
|
||||||
.join('\n') + (logs.length ? '\n' : '')
|
|
||||||
container?.append(newLogs)
|
|
||||||
this.loadingMore = false
|
|
||||||
this.scrollEvent()
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollEvent() {
|
|
||||||
const buttonDiv = document.getElementById('button-div')
|
|
||||||
this.isOnBottom =
|
this.isOnBottom =
|
||||||
!!buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight
|
!!bottomDiv &&
|
||||||
|
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottom() {
|
scrollToBottom() {
|
||||||
this.content?.scrollToBottom(500)
|
this.content?.scrollToBottom(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadData(e: any): Promise<void> {
|
async doInfinite(e: any): Promise<void> {
|
||||||
await this.getLogs()
|
await this.getLogs()
|
||||||
e.target.complete()
|
e.target.complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getLogs() {
|
||||||
|
try {
|
||||||
|
const { 'start-cursor': startCursor, entries } = await this.api.getLogs({
|
||||||
|
cursor: this.startCursor,
|
||||||
|
before: !!this.startCursor,
|
||||||
|
limit: this.limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!entries.length) return
|
||||||
|
|
||||||
|
this.startCursor = startCursor
|
||||||
|
|
||||||
|
const container = document.getElementById('container')
|
||||||
|
const newLogs = document.getElementById('template')?.cloneNode(true)
|
||||||
|
|
||||||
|
if (!(newLogs instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
newLogs.innerHTML = entries
|
||||||
|
.map(
|
||||||
|
entry =>
|
||||||
|
`<b>${toLocalIsoString(
|
||||||
|
new Date(entry.timestamp),
|
||||||
|
)}</b> ${convert.toHtml(entry.message)}`,
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const beforeContainerHeight = container?.scrollHeight || 0
|
||||||
|
container?.prepend(newLogs)
|
||||||
|
const afterContainerHeight = container?.scrollHeight || 0
|
||||||
|
|
||||||
|
// scroll down
|
||||||
|
setTimeout(() => {
|
||||||
|
this.content?.scrollToPoint(
|
||||||
|
0,
|
||||||
|
afterContainerHeight - beforeContainerHeight,
|
||||||
|
)
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
if (entries.length < this.limit) {
|
||||||
|
this.needInfinite = false
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errToast.present(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||||
|
|
||||||
export abstract class ApiService {
|
export abstract class ApiService {
|
||||||
abstract getError(): Promise<GetErrorRes>
|
abstract getError(): Promise<GetErrorRes>
|
||||||
abstract restart(): Promise<void>
|
abstract restart(): Promise<void>
|
||||||
abstract forgetDrive(): Promise<void>
|
abstract forgetDrive(): Promise<void>
|
||||||
abstract repairDisk(): Promise<void>
|
abstract repairDisk(): Promise<void>
|
||||||
abstract getLogs(params: GetLogsReq): Promise<GetLogsRes>
|
abstract getLogs(params: ServerLogsReq): Promise<LogsRes>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetErrorRes {
|
export interface GetErrorRes {
|
||||||
@@ -11,21 +13,3 @@ export interface GetErrorRes {
|
|||||||
message: string
|
message: string
|
||||||
data: { details: string }
|
data: { details: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetLogsReq = {
|
|
||||||
cursor?: string
|
|
||||||
before_flag?: boolean
|
|
||||||
limit?: number
|
|
||||||
}
|
|
||||||
export type GetLogsRes = LogsRes
|
|
||||||
|
|
||||||
export type LogsRes = {
|
|
||||||
entries: Log[]
|
|
||||||
'start-cursor'?: string
|
|
||||||
'end-cursor'?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Log {
|
|
||||||
timestamp: string
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { HttpService } from '../http.service'
|
import { HttpService } from '@start9labs/shared'
|
||||||
import { ApiService, GetErrorRes, GetLogsReq, GetLogsRes } from './api.service'
|
import { ApiService, GetErrorRes } from './api.service'
|
||||||
|
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LiveApiService extends ApiService {
|
export class LiveApiService extends ApiService {
|
||||||
@@ -36,8 +37,8 @@ export class LiveApiService extends ApiService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getLogs(params: GetLogsReq): Promise<GetLogsRes> {
|
getLogs(params: ServerLogsReq): Promise<LogsRes> {
|
||||||
return this.http.rpcRequest<GetLogsRes>({
|
return this.http.rpcRequest<LogsRes>({
|
||||||
method: 'diagnostic.logs',
|
method: 'diagnostic.logs',
|
||||||
params,
|
params,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { pauseFor } from '@start9labs/shared'
|
import { pauseFor } from '@start9labs/shared'
|
||||||
import {
|
import { ApiService, GetErrorRes } from './api.service'
|
||||||
ApiService,
|
import { LogsRes, ServerLogsReq, Log } from '@start9labs/shared'
|
||||||
GetErrorRes,
|
|
||||||
GetLogsReq,
|
|
||||||
GetLogsRes,
|
|
||||||
Log,
|
|
||||||
} from './api.service'
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MockApiService extends ApiService {
|
export class MockApiService extends ApiService {
|
||||||
@@ -35,7 +30,7 @@ export class MockApiService extends ApiService {
|
|||||||
await pauseFor(1000)
|
await pauseFor(1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLogs(params: GetLogsReq): Promise<GetLogsRes> {
|
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
|
||||||
await pauseFor(1000)
|
await pauseFor(1000)
|
||||||
let entries: Log[]
|
let entries: Log[]
|
||||||
if (Math.random() < 0.2) {
|
if (Math.random() < 0.2) {
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core'
|
|
||||||
import { HttpClient } from '@angular/common/http'
|
|
||||||
import { HttpError, RpcError } from '@start9labs/shared'
|
|
||||||
import { firstValueFrom } from 'rxjs'
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class HttpService {
|
|
||||||
constructor(private readonly http: HttpClient) {}
|
|
||||||
|
|
||||||
async rpcRequest<T>(options: RPCOptions): Promise<T> {
|
|
||||||
const res = await this.httpRequest<RPCResponse<T>>(options)
|
|
||||||
if (isRpcError(res)) throw new RpcError(res.error)
|
|
||||||
return res.result
|
|
||||||
}
|
|
||||||
|
|
||||||
async httpRequest<T>(body: RPCOptions): Promise<T> {
|
|
||||||
const url = `${window.location.protocol}//${window.location.hostname}:${window.location.port}/rpc/v1`
|
|
||||||
return firstValueFrom(this.http.post<T>(url, body)).catch(e => {
|
|
||||||
throw new HttpError(e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRpcError<Error, Result>(
|
|
||||||
arg: { error: Error } | { result: Result },
|
|
||||||
): arg is { error: Error } {
|
|
||||||
return (arg as any).error !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCOptions {
|
|
||||||
method: string
|
|
||||||
params: { [param: string]: Params }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestError {
|
|
||||||
code: number
|
|
||||||
message: string
|
|
||||||
details: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Params = string | number | boolean | object | string[] | number[]
|
|
||||||
|
|
||||||
interface RPCBase {
|
|
||||||
jsonrpc: '2.0'
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCRequest<T> extends RPCBase {
|
|
||||||
method: string
|
|
||||||
params?: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCSuccess<T> extends RPCBase {
|
|
||||||
result: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCError extends RPCBase {
|
|
||||||
error: {
|
|
||||||
code: number
|
|
||||||
message: string
|
|
||||||
data?:
|
|
||||||
| {
|
|
||||||
details: string
|
|
||||||
}
|
|
||||||
| string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RPCResponse<T> = RPCSuccess<T> | RPCError
|
|
||||||
@@ -5,7 +5,6 @@ import { HttpClientModule } from '@angular/common/http'
|
|||||||
import { ApiService } from './services/api/api.service'
|
import { ApiService } from './services/api/api.service'
|
||||||
import { MockApiService } from './services/api/mock-api.service'
|
import { MockApiService } from './services/api/mock-api.service'
|
||||||
import { LiveApiService } from './services/api/live-api.service'
|
import { LiveApiService } from './services/api/live-api.service'
|
||||||
import { HttpService } from './services/api/http.service'
|
|
||||||
import {
|
import {
|
||||||
IonicModule,
|
IonicModule,
|
||||||
IonicRouteStrategy,
|
IonicRouteStrategy,
|
||||||
@@ -45,14 +44,7 @@ const { useMocks } = require('../../../../config.json') as WorkspaceConfig
|
|||||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||||
{
|
{
|
||||||
provide: ApiService,
|
provide: ApiService,
|
||||||
useFactory: (http: HttpService) => {
|
useClass: useMocks ? MockApiService : LiveApiService,
|
||||||
if (useMocks) {
|
|
||||||
return new MockApiService()
|
|
||||||
} else {
|
|
||||||
return new LiveApiService(http)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deps: [HttpService],
|
|
||||||
},
|
},
|
||||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
|
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { CanActivate, Router } from '@angular/router'
|
import { CanActivate, Router } from '@angular/router'
|
||||||
import { HttpService } from '../services/api/http.service'
|
import { RPCEncryptedService } from '../services/rpc-encrypted.service'
|
||||||
import { StateService } from '../services/state.service'
|
import { StateService } from '../services/state.service'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class NavGuard implements CanActivate {
|
export class NavGuard implements CanActivate {
|
||||||
constructor (
|
constructor(
|
||||||
private readonly router: Router,
|
private readonly router: Router,
|
||||||
private readonly httpService: HttpService,
|
private readonly encrypted: RPCEncryptedService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
canActivate (): boolean {
|
canActivate(): boolean {
|
||||||
if (this.httpService.productKey) {
|
if (this.encrypted.productKey) {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
this.router.navigateByUrl('product-key')
|
this.router.navigateByUrl('product-key')
|
||||||
@@ -26,14 +26,14 @@ export class NavGuard implements CanActivate {
|
|||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class RecoveryNavGuard implements CanActivate {
|
export class RecoveryNavGuard implements CanActivate {
|
||||||
constructor (
|
constructor(
|
||||||
private readonly router: Router,
|
private readonly router: Router,
|
||||||
private readonly httpService: HttpService,
|
private readonly encrypted: RPCEncryptedService,
|
||||||
private readonly stateService: StateService,
|
private readonly stateService: StateService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
canActivate (): boolean {
|
canActivate(): boolean {
|
||||||
if (this.httpService.productKey || !this.stateService.hasProductKey) {
|
if (this.encrypted.productKey || !this.stateService.hasProductKey) {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
this.router.navigateByUrl('product-key')
|
this.router.navigateByUrl('product-key')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, Input, ViewChild } from '@angular/core'
|
import { Component, Input, ViewChild } from '@angular/core'
|
||||||
import { IonInput, LoadingController, ModalController } from '@ionic/angular'
|
import { IonInput, LoadingController, ModalController } from '@ionic/angular'
|
||||||
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
|
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||||
import { HttpService } from 'src/app/services/api/http.service'
|
import { RPCEncryptedService } from 'src/app/services/rpc-encrypted.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'prod-key-modal',
|
selector: 'prod-key-modal',
|
||||||
@@ -20,7 +20,7 @@ export class ProdKeyModal {
|
|||||||
private readonly modalController: ModalController,
|
private readonly modalController: ModalController,
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
private readonly loadingCtrl: LoadingController,
|
private readonly loadingCtrl: LoadingController,
|
||||||
private readonly httpService: HttpService,
|
private readonly encrypted: RPCEncryptedService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
@@ -37,11 +37,11 @@ export class ProdKeyModal {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.apiService.set02XDrive(this.target.logicalname)
|
await this.apiService.set02XDrive(this.target.logicalname)
|
||||||
this.httpService.productKey = this.productKey
|
this.encrypted.productKey = this.productKey
|
||||||
await this.apiService.verifyProductKey()
|
await this.apiService.verifyProductKey()
|
||||||
this.modalController.dismiss({ productKey: this.productKey }, 'success')
|
this.modalController.dismiss({ productKey: this.productKey }, 'success')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.httpService.productKey = undefined
|
this.encrypted.productKey = undefined
|
||||||
this.error = 'Invalid Product Key'
|
this.error = 'Invalid Product Key'
|
||||||
} finally {
|
} finally {
|
||||||
loader.dismiss()
|
loader.dismiss()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, ViewChild } from '@angular/core'
|
import { Component, ViewChild } from '@angular/core'
|
||||||
import { IonInput, LoadingController, NavController } from '@ionic/angular'
|
import { IonInput, LoadingController, NavController } from '@ionic/angular'
|
||||||
import { ApiService } from 'src/app/services/api/api.service'
|
import { ApiService } from 'src/app/services/api/api.service'
|
||||||
import { HttpService } from 'src/app/services/api/http.service'
|
import { RPCEncryptedService } from 'src/app/services/rpc-encrypted.service'
|
||||||
import { StateService } from 'src/app/services/state.service'
|
import { StateService } from 'src/app/services/state.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -19,7 +19,7 @@ export class ProductKeyPage {
|
|||||||
private readonly stateService: StateService,
|
private readonly stateService: StateService,
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
private readonly loadingCtrl: LoadingController,
|
private readonly loadingCtrl: LoadingController,
|
||||||
private readonly httpService: HttpService,
|
private readonly encrypted: RPCEncryptedService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ionViewDidEnter() {
|
ionViewDidEnter() {
|
||||||
@@ -35,7 +35,7 @@ export class ProductKeyPage {
|
|||||||
await loader.present()
|
await loader.present()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.httpService.productKey = this.productKey
|
this.encrypted.productKey = this.productKey
|
||||||
await this.apiService.verifyProductKey()
|
await this.apiService.verifyProductKey()
|
||||||
if (this.stateService.isMigrating) {
|
if (this.stateService.isMigrating) {
|
||||||
await this.navCtrl.navigateForward(`/loading`)
|
await this.navCtrl.navigateForward(`/loading`)
|
||||||
@@ -44,7 +44,7 @@ export class ProductKeyPage {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = 'Invalid Product Key'
|
this.error = 'Invalid Product Key'
|
||||||
this.httpService.productKey = undefined
|
this.encrypted.productKey = undefined
|
||||||
} finally {
|
} finally {
|
||||||
loader.dismiss()
|
loader.dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
style="font-size: 80px"
|
style="font-size: 80px"
|
||||||
name="checkmark-circle-outline"
|
name="checkmark-circle-outline"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
<ion-card-title>Setup Complete!</ion-card-title>
|
<ion-card-title>Setup Complete</ion-card-title>
|
||||||
</ion-card-header>
|
</ion-card-header>
|
||||||
<ion-card-content>
|
<ion-card-content>
|
||||||
<br />
|
<br />
|
||||||
@@ -17,61 +17,21 @@
|
|||||||
>
|
>
|
||||||
<h2>You can now safely unplug your backup drive.</h2>
|
<h2>You can now safely unplug your backup drive.</h2>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<!-- Tor Instructions -->
|
<h2>
|
||||||
<div (click)="toggleTor()" class="toggle-label">
|
You have successully claimed your Embassy! You can now access your
|
||||||
<h2>Tor Instructions:</h2>
|
device using the methods below.
|
||||||
<ion-icon
|
</h2>
|
||||||
name="chevron-down-outline"
|
|
||||||
[ngStyle]="{
|
|
||||||
'transform': torOpen ? 'rotate(-90deg)' : 'rotate(0deg)',
|
|
||||||
'transition': 'transform 0.4s ease-out'
|
|
||||||
}"
|
|
||||||
></ion-icon>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<br />
|
||||||
[ngStyle]="{
|
|
||||||
'overflow' : 'hidden',
|
<p>
|
||||||
'max-height': torOpen ? '500px' : '0px',
|
<b>Note:</b> embassy.local was for setup purposes only, it will no
|
||||||
'transition': 'max-height 0.4s ease-out'
|
longer work.
|
||||||
}"
|
</p>
|
||||||
>
|
|
||||||
<div class="ion-padding ion-text-start">
|
|
||||||
<p>
|
|
||||||
To use your Embassy over Tor, visit its unique Tor address
|
|
||||||
from any Tor-enabled browser. For a list of recommended
|
|
||||||
browsers, click
|
|
||||||
<a
|
|
||||||
href="https://start9.com/latest/user-manual/connecting/connecting-tor"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
><b>here</b></a
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<p>Tor Address</p>
|
|
||||||
<ion-item lines="none" color="dark">
|
|
||||||
<ion-label class="ion-text-wrap">
|
|
||||||
<code
|
|
||||||
><ion-text color="light">{{ torAddress }}</ion-text></code
|
|
||||||
>
|
|
||||||
</ion-label>
|
|
||||||
<ion-button
|
|
||||||
color="light"
|
|
||||||
fill="clear"
|
|
||||||
(click)="copy(torAddress)"
|
|
||||||
>
|
|
||||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
</ion-item>
|
|
||||||
</div>
|
|
||||||
<div style="padding-bottom: 24px; border-bottom: solid 1px"></div>
|
|
||||||
<br />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- LAN Instructions -->
|
<!-- LAN Instructions -->
|
||||||
<div (click)="toggleLan()" class="toggle-label">
|
<div (click)="toggleLan()" class="toggle-label">
|
||||||
<h2>LAN Instructions (Slightly Advanced):</h2>
|
<h2>From Home (LAN)</h2>
|
||||||
<ion-icon
|
<ion-icon
|
||||||
name="chevron-down-outline"
|
name="chevron-down-outline"
|
||||||
[ngStyle]="{
|
[ngStyle]="{
|
||||||
@@ -89,49 +49,21 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="ion-padding ion-text-start">
|
<div class="ion-padding ion-text-start">
|
||||||
<p>To use your Embassy locally, you must:</p>
|
|
||||||
<ol>
|
|
||||||
<li>
|
|
||||||
Currently be connected to the same Local Area Network (LAN)
|
|
||||||
as your Embassy.
|
|
||||||
</li>
|
|
||||||
<li>Download your Embassy's Root Certificate Authority.</li>
|
|
||||||
<li>
|
|
||||||
Trust your Embassy's Root CA on <i>both</i> your
|
|
||||||
computer/phone and in your browser settings.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p>
|
<p>
|
||||||
For step-by-step instructions, click
|
Visit the address below when you are conncted to the same WiFi
|
||||||
<a
|
or Local Area Network (LAN) as your Embassy:
|
||||||
href="https://start9.com/latest/user-manual/connecting/connecting-lan"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
><b>here</b></a
|
|
||||||
>.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<ion-item
|
||||||
<b
|
lines="none"
|
||||||
>Please note, once setup is complete, the embassy.local
|
color="dark"
|
||||||
address will no longer connect to your Embassy.</b
|
class="ion-padding-top ion-padding-bottom"
|
||||||
>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ion-button
|
|
||||||
style="margin-top: 24px; margin-bottom: 24px"
|
|
||||||
color="light"
|
|
||||||
(click)="installCert()"
|
|
||||||
>
|
>
|
||||||
Download Root CA
|
|
||||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
|
|
||||||
<p>LAN Address</p>
|
|
||||||
<ion-item lines="none" color="dark">
|
|
||||||
<ion-label class="ion-text-wrap">
|
<ion-label class="ion-text-wrap">
|
||||||
<code
|
<code
|
||||||
><ion-text color="light">{{ lanAddress }}</ion-text></code
|
><ion-text color="light"
|
||||||
|
><b>{{ lanAddress }}</b></ion-text
|
||||||
|
></code
|
||||||
>
|
>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
<ion-button
|
<ion-button
|
||||||
@@ -142,10 +74,96 @@
|
|||||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<b>Important!</b>
|
||||||
|
Your browser will warn you that the website is untrusted. You
|
||||||
|
can bypass this warning on most browsers. The warning will go
|
||||||
|
away after you
|
||||||
|
<a
|
||||||
|
href="https://start9.com/latest/user-manual/connecting/connecting-lan"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<b>download and trust</b>
|
||||||
|
</a>
|
||||||
|
your Embassy's Root Certificate Authority.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ion-button
|
||||||
|
style="margin-top: 24px; margin-bottom: 24px"
|
||||||
|
color="light"
|
||||||
|
(click)="installCert()"
|
||||||
|
>
|
||||||
|
Download Root CA
|
||||||
|
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding-bottom: 24px; border-bottom: solid 1px"></div>
|
<div style="padding-bottom: 24px; border-bottom: solid 1px"></div>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tor Instructions -->
|
||||||
|
<div (click)="toggleTor()" class="toggle-label">
|
||||||
|
<h2>On The Go (Tor)</h2>
|
||||||
|
<ion-icon
|
||||||
|
name="chevron-down-outline"
|
||||||
|
[ngStyle]="{
|
||||||
|
'transform': torOpen ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||||
|
'transition': 'transform 0.4s ease-out'
|
||||||
|
}"
|
||||||
|
></ion-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
[ngStyle]="{
|
||||||
|
'overflow' : 'hidden',
|
||||||
|
'max-height': torOpen ? '500px' : '0px',
|
||||||
|
'transition': 'max-height 0.4s ease-out'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="ion-padding ion-text-start">
|
||||||
|
<p>Visit the address below when you are away from home:</p>
|
||||||
|
|
||||||
|
<ion-item
|
||||||
|
lines="none"
|
||||||
|
color="dark"
|
||||||
|
class="ion-padding-top ion-padding-bottom"
|
||||||
|
>
|
||||||
|
<ion-label class="ion-text-wrap">
|
||||||
|
<code
|
||||||
|
><ion-text color="light"
|
||||||
|
><b>{{ torAddress }}</b></ion-text
|
||||||
|
></code
|
||||||
|
>
|
||||||
|
</ion-label>
|
||||||
|
<ion-button
|
||||||
|
color="light"
|
||||||
|
fill="clear"
|
||||||
|
(click)="copy(torAddress)"
|
||||||
|
>
|
||||||
|
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<b>Important!</b>
|
||||||
|
This address will only work from a
|
||||||
|
<a
|
||||||
|
href="https://start9.com/latest/user-manual/connecting/connecting-tor"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<b>Tor-enabled browser</b> </a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding-bottom: 24px; border-bottom: solid 1px"></div>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ion-text-center ion-padding-top">
|
<div class="ion-text-center ion-padding-top">
|
||||||
<ion-button
|
<ion-button
|
||||||
color="light"
|
color="light"
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import { Component, EventEmitter, Output } from '@angular/core'
|
import { DOCUMENT } from '@angular/common'
|
||||||
|
import { Component, EventEmitter, Inject, Output } from '@angular/core'
|
||||||
import { ToastController } from '@ionic/angular'
|
import { ToastController } from '@ionic/angular'
|
||||||
import { ErrorToastService } from '@start9labs/shared'
|
import {
|
||||||
|
copyToClipboard,
|
||||||
|
DownloadHTMLService,
|
||||||
|
ErrorToastService,
|
||||||
|
} from '@start9labs/shared'
|
||||||
import { StateService } from 'src/app/services/state.service'
|
import { StateService } from 'src/app/services/state.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'success',
|
selector: 'success',
|
||||||
templateUrl: 'success.page.html',
|
templateUrl: 'success.page.html',
|
||||||
styleUrls: ['success.page.scss'],
|
styleUrls: ['success.page.scss'],
|
||||||
|
providers: [DownloadHTMLService],
|
||||||
})
|
})
|
||||||
export class SuccessPage {
|
export class SuccessPage {
|
||||||
@Output() onDownload = new EventEmitter()
|
@Output() onDownload = new EventEmitter()
|
||||||
torOpen = true
|
torOpen = false
|
||||||
lanOpen = false
|
lanOpen = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DOCUMENT) private readonly document: Document,
|
||||||
private readonly toastCtrl: ToastController,
|
private readonly toastCtrl: ToastController,
|
||||||
private readonly errCtrl: ErrorToastService,
|
private readonly errCtrl: ErrorToastService,
|
||||||
private readonly stateService: StateService,
|
private readonly stateService: StateService,
|
||||||
|
private readonly downloadHtml: DownloadHTMLService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get recoverySource() {
|
get recoverySource() {
|
||||||
@@ -34,7 +42,7 @@ export class SuccessPage {
|
|||||||
async ngAfterViewInit() {
|
async ngAfterViewInit() {
|
||||||
try {
|
try {
|
||||||
await this.stateService.completeEmbassy()
|
await this.stateService.completeEmbassy()
|
||||||
document
|
this.document
|
||||||
.getElementById('install-cert')
|
.getElementById('install-cert')
|
||||||
?.setAttribute(
|
?.setAttribute(
|
||||||
'href',
|
'href',
|
||||||
@@ -48,7 +56,7 @@ export class SuccessPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async copy(address: string): Promise<void> {
|
async copy(address: string): Promise<void> {
|
||||||
const success = await this.copyToClipboard(address)
|
const success = await copyToClipboard(address)
|
||||||
const message = success
|
const message = success
|
||||||
? 'Copied to clipboard!'
|
? 'Copied to clipboard!'
|
||||||
: 'Failed to copy to clipboard.'
|
: 'Failed to copy to clipboard.'
|
||||||
@@ -70,49 +78,24 @@ export class SuccessPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
installCert() {
|
installCert() {
|
||||||
document.getElementById('install-cert')?.click()
|
this.document.getElementById('install-cert')?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
download() {
|
download() {
|
||||||
const torAddress = document.getElementById('tor-addr')
|
const torAddress = this.document.getElementById('tor-addr')
|
||||||
const lanAddress = document.getElementById('lan-addr')
|
const lanAddress = this.document.getElementById('lan-addr')
|
||||||
|
|
||||||
if (torAddress) torAddress.innerHTML = this.stateService.torAddress
|
if (torAddress) torAddress.innerHTML = this.stateService.torAddress
|
||||||
if (lanAddress) lanAddress.innerHTML = this.stateService.lanAddress
|
if (lanAddress) lanAddress.innerHTML = this.stateService.lanAddress
|
||||||
|
|
||||||
document
|
this.document
|
||||||
.getElementById('cert')
|
.getElementById('cert')
|
||||||
?.setAttribute(
|
?.setAttribute(
|
||||||
'href',
|
'href',
|
||||||
'data:application/x-x509-ca-cert;base64,' +
|
'data:application/x-x509-ca-cert;base64,' +
|
||||||
encodeURIComponent(this.stateService.cert),
|
encodeURIComponent(this.stateService.cert),
|
||||||
)
|
)
|
||||||
let html = document.getElementById('downloadable')?.innerHTML || ''
|
let html = this.document.getElementById('downloadable')?.innerHTML || ''
|
||||||
const filename = 'embassy-info.html'
|
this.downloadHtml.download('embassy-info.html', html)
|
||||||
|
|
||||||
const elem = document.createElement('a')
|
|
||||||
elem.setAttribute(
|
|
||||||
'href',
|
|
||||||
'data:text/plain;charset=utf-8,' + encodeURIComponent(html),
|
|
||||||
)
|
|
||||||
elem.setAttribute('download', filename)
|
|
||||||
elem.style.display = 'none'
|
|
||||||
|
|
||||||
document.body.appendChild(elem)
|
|
||||||
elem.click()
|
|
||||||
document.body.removeChild(elem)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async copyToClipboard(str: string): Promise<boolean> {
|
|
||||||
const el = document.createElement('textarea')
|
|
||||||
el.value = str
|
|
||||||
el.setAttribute('readonly', '')
|
|
||||||
el.style.position = 'absolute'
|
|
||||||
el.style.left = '-9999px'
|
|
||||||
document.body.appendChild(el)
|
|
||||||
el.select()
|
|
||||||
const copy = document.execCommand('copy')
|
|
||||||
document.body.removeChild(el)
|
|
||||||
return copy
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,42 +13,42 @@ export abstract class ApiService {
|
|||||||
abstract setupComplete(): Promise<SetupEmbassyRes> // setup.complete
|
abstract setupComplete(): Promise<SetupEmbassyRes> // setup.complete
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetStatusRes {
|
export type GetStatusRes = {
|
||||||
'product-key': boolean
|
'product-key': boolean
|
||||||
migrating: boolean
|
migrating: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportDriveReq {
|
export type ImportDriveReq = {
|
||||||
guid: string
|
guid: string
|
||||||
'embassy-password': string
|
'embassy-password': string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SetupEmbassyReq {
|
export type SetupEmbassyReq = {
|
||||||
'embassy-logicalname': string
|
'embassy-logicalname': string
|
||||||
'embassy-password': string
|
'embassy-password': string
|
||||||
'recovery-source': CifsRecoverySource | DiskRecoverySource | null
|
'recovery-source': CifsRecoverySource | DiskRecoverySource | null
|
||||||
'recovery-password': string | null
|
'recovery-password': string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SetupEmbassyRes {
|
export type SetupEmbassyRes = {
|
||||||
'tor-address': string
|
'tor-address': string
|
||||||
'lan-address': string
|
'lan-address': string
|
||||||
'root-ca': string
|
'root-ca': string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmbassyOSRecoveryInfo {
|
export type EmbassyOSRecoveryInfo = {
|
||||||
version: string
|
version: string
|
||||||
full: boolean
|
full: boolean
|
||||||
'password-hash': string | null
|
'password-hash': string | null
|
||||||
'wrapped-key': string | null
|
'wrapped-key': string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiskListResponse {
|
export type DiskListResponse = {
|
||||||
disks: DiskInfo[]
|
disks: DiskInfo[]
|
||||||
reconnect: string[]
|
reconnect: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiskBackupTarget {
|
export type DiskBackupTarget = {
|
||||||
vendor: string | null
|
vendor: string | null
|
||||||
model: string | null
|
model: string | null
|
||||||
logicalname: string | null
|
logicalname: string | null
|
||||||
@@ -58,7 +58,7 @@ export interface DiskBackupTarget {
|
|||||||
'embassy-os': EmbassyOSRecoveryInfo | null
|
'embassy-os': EmbassyOSRecoveryInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CifsBackupTarget {
|
export type CifsBackupTarget = {
|
||||||
hostname: string
|
hostname: string
|
||||||
path: string
|
path: string
|
||||||
username: string
|
username: string
|
||||||
@@ -66,12 +66,12 @@ export interface CifsBackupTarget {
|
|||||||
'embassy-os': EmbassyOSRecoveryInfo | null
|
'embassy-os': EmbassyOSRecoveryInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiskRecoverySource {
|
export type DiskRecoverySource = {
|
||||||
type: 'disk'
|
type: 'disk'
|
||||||
logicalname: string // partition logicalname
|
logicalname: string // partition logicalname
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CifsRecoverySource {
|
export type CifsRecoverySource = {
|
||||||
type: 'cifs'
|
type: 'cifs'
|
||||||
hostname: string
|
hostname: string
|
||||||
path: string
|
path: string
|
||||||
@@ -79,7 +79,7 @@ export interface CifsRecoverySource {
|
|||||||
password: string | null
|
password: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiskInfo {
|
export type DiskInfo = {
|
||||||
logicalname: string
|
logicalname: string
|
||||||
vendor: string | null
|
vendor: string | null
|
||||||
model: string | null
|
model: string | null
|
||||||
@@ -88,13 +88,13 @@ export interface DiskInfo {
|
|||||||
guid: string | null // cant back up if guid exists
|
guid: string | null // cant back up if guid exists
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecoveryStatusRes {
|
export type RecoveryStatusRes = {
|
||||||
'bytes-transferred': number
|
'bytes-transferred': number
|
||||||
'total-bytes': number
|
'total-bytes': number
|
||||||
complete: boolean
|
complete: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartitionInfo {
|
export type PartitionInfo = {
|
||||||
logicalname: string
|
logicalname: string
|
||||||
label: string | null
|
label: string | null
|
||||||
capacity: number
|
capacity: number
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core'
|
|
||||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
|
|
||||||
import { firstValueFrom, Observable } from 'rxjs'
|
|
||||||
import * as aesjs from 'aes-js'
|
|
||||||
import * as pbkdf2 from 'pbkdf2'
|
|
||||||
import { HttpError, RpcError } from '@start9labs/shared'
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class HttpService {
|
|
||||||
fullUrl: string
|
|
||||||
productKey?: string
|
|
||||||
|
|
||||||
constructor(private readonly http: HttpClient) {
|
|
||||||
const port = window.location.port
|
|
||||||
this.fullUrl = `${window.location.protocol}//${window.location.hostname}:${port}/rpc/v1`
|
|
||||||
}
|
|
||||||
|
|
||||||
async rpcRequest<T>(body: RPCOptions, encrypted = true): Promise<T> {
|
|
||||||
const httpOpts = {
|
|
||||||
method: Method.POST,
|
|
||||||
body,
|
|
||||||
url: this.fullUrl,
|
|
||||||
}
|
|
||||||
|
|
||||||
let res: RPCResponse<T>
|
|
||||||
|
|
||||||
if (encrypted) {
|
|
||||||
res = await this.encryptedHttpRequest<RPCResponse<T>>(httpOpts)
|
|
||||||
} else {
|
|
||||||
res = await this.httpRequest<RPCResponse<T>>(httpOpts)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRpcError(res)) {
|
|
||||||
console.error('RPC ERROR: ', res)
|
|
||||||
throw new RpcError(res.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.result
|
|
||||||
}
|
|
||||||
|
|
||||||
async encryptedHttpRequest<T>(httpOpts: {
|
|
||||||
body: RPCOptions
|
|
||||||
url: string
|
|
||||||
}): Promise<T> {
|
|
||||||
const urlIsRelative = httpOpts.url.startsWith('/')
|
|
||||||
const url = urlIsRelative ? this.fullUrl + httpOpts.url : httpOpts.url
|
|
||||||
|
|
||||||
const encryptedBody = await AES_CTR.encryptPbkdf2(
|
|
||||||
this.productKey || '',
|
|
||||||
encodeUtf8(JSON.stringify(httpOpts.body)),
|
|
||||||
)
|
|
||||||
const options = {
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
body: encryptedBody.buffer,
|
|
||||||
observe: 'response',
|
|
||||||
headers: {
|
|
||||||
'Content-Encoding': 'aesctr256',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
} as any
|
|
||||||
|
|
||||||
const req = this.http.post(url, options.body, options)
|
|
||||||
|
|
||||||
return firstValueFrom(req)
|
|
||||||
.then(res =>
|
|
||||||
AES_CTR.decryptPbkdf2(
|
|
||||||
this.productKey || '',
|
|
||||||
(res as any).body as ArrayBuffer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then(res => JSON.parse(res))
|
|
||||||
.catch(e => {
|
|
||||||
if (!e.status && !e.statusText) {
|
|
||||||
throw new EncryptionError()
|
|
||||||
} else {
|
|
||||||
throw new HttpError(e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async httpRequest<T>(httpOpts: {
|
|
||||||
body: RPCOptions
|
|
||||||
url: string
|
|
||||||
}): Promise<T> {
|
|
||||||
const urlIsRelative = httpOpts.url.startsWith('/')
|
|
||||||
const url = urlIsRelative ? this.fullUrl + httpOpts.url : httpOpts.url
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
responseType: 'json',
|
|
||||||
body: httpOpts.body,
|
|
||||||
observe: 'response',
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
accept: 'application/json',
|
|
||||||
},
|
|
||||||
} as any
|
|
||||||
|
|
||||||
const req: Observable<{ body: T }> = this.http.post(
|
|
||||||
url,
|
|
||||||
httpOpts.body,
|
|
||||||
options,
|
|
||||||
) as any
|
|
||||||
|
|
||||||
return firstValueFrom(req)
|
|
||||||
.then(res => res.body)
|
|
||||||
.catch(e => {
|
|
||||||
throw new HttpError(e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class EncryptionError {
|
|
||||||
readonly code = null
|
|
||||||
readonly message = 'Invalid Key'
|
|
||||||
readonly details = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRpcError<Error, Result>(
|
|
||||||
arg: { error: Error } | { result: Result },
|
|
||||||
): arg is { error: Error } {
|
|
||||||
return (arg as any).error !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Method {
|
|
||||||
GET = 'GET',
|
|
||||||
POST = 'POST',
|
|
||||||
PUT = 'PUT',
|
|
||||||
PATCH = 'PATCH',
|
|
||||||
DELETE = 'DELETE',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCOptions {
|
|
||||||
method: string
|
|
||||||
params?: {
|
|
||||||
[param: string]: string | number | boolean | object | string[] | number[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RPCBase {
|
|
||||||
jsonrpc: '2.0'
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCRequest<T> extends RPCBase {
|
|
||||||
method: string
|
|
||||||
params?: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCSuccess<T> extends RPCBase {
|
|
||||||
result: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCError extends RPCBase {
|
|
||||||
error: {
|
|
||||||
code: number
|
|
||||||
message: string
|
|
||||||
data?:
|
|
||||||
| {
|
|
||||||
details: string
|
|
||||||
}
|
|
||||||
| string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RPCResponse<T> = RPCSuccess<T> | RPCError
|
|
||||||
|
|
||||||
export interface HttpOptions {
|
|
||||||
method: Method
|
|
||||||
url: string
|
|
||||||
headers?:
|
|
||||||
| HttpHeaders
|
|
||||||
| {
|
|
||||||
[header: string]: string | string[]
|
|
||||||
}
|
|
||||||
params?:
|
|
||||||
| HttpParams
|
|
||||||
| {
|
|
||||||
[param: string]: string | string[]
|
|
||||||
}
|
|
||||||
responseType?: 'json' | 'text' | 'arrayBuffer'
|
|
||||||
withCredentials?: boolean
|
|
||||||
body?: any
|
|
||||||
timeout?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type AES_CTR = {
|
|
||||||
encryptPbkdf2: (
|
|
||||||
secretKey: string,
|
|
||||||
messageBuffer: Uint8Array,
|
|
||||||
) => Promise<Uint8Array>
|
|
||||||
decryptPbkdf2: (secretKey: string, arr: ArrayBuffer) => Promise<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AES_CTR: AES_CTR = {
|
|
||||||
encryptPbkdf2: async (secretKey: string, messageBuffer: Uint8Array) => {
|
|
||||||
const salt = window.crypto.getRandomValues(new Uint8Array(16))
|
|
||||||
const counter = window.crypto.getRandomValues(new Uint8Array(16))
|
|
||||||
|
|
||||||
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
|
|
||||||
|
|
||||||
const aesCtr = new aesjs.ModeOfOperation.ctr(
|
|
||||||
key,
|
|
||||||
new aesjs.Counter(counter),
|
|
||||||
)
|
|
||||||
const encryptedBytes = aesCtr.encrypt(messageBuffer)
|
|
||||||
return new Uint8Array([...counter, ...salt, ...encryptedBytes])
|
|
||||||
},
|
|
||||||
decryptPbkdf2: async (secretKey: string, arr: ArrayBuffer) => {
|
|
||||||
const buff = new Uint8Array(arr)
|
|
||||||
const counter = buff.slice(0, 16)
|
|
||||||
const salt = buff.slice(16, 32)
|
|
||||||
|
|
||||||
const cipher = buff.slice(32)
|
|
||||||
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
|
|
||||||
|
|
||||||
const aesCtr = new aesjs.ModeOfOperation.ctr(
|
|
||||||
key,
|
|
||||||
new aesjs.Counter(counter),
|
|
||||||
)
|
|
||||||
const decryptedBytes = aesCtr.decrypt(cipher)
|
|
||||||
|
|
||||||
return aesjs.utils.utf8.fromBytes(decryptedBytes)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const encode16 = (buffer: Uint8Array) =>
|
|
||||||
buffer.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '')
|
|
||||||
export const decode16 = (hexString: string) =>
|
|
||||||
new Uint8Array(
|
|
||||||
hexString.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || [],
|
|
||||||
)
|
|
||||||
|
|
||||||
export function encodeUtf8(str: string): Uint8Array {
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
return encoder.encode(str)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeUtf8(arr: Uint8Array): string {
|
|
||||||
return new TextDecoder().decode(arr)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
|
import { HttpService } from '@start9labs/shared'
|
||||||
import {
|
import {
|
||||||
ApiService,
|
ApiService,
|
||||||
CifsRecoverySource,
|
CifsRecoverySource,
|
||||||
@@ -11,79 +12,70 @@ import {
|
|||||||
SetupEmbassyReq,
|
SetupEmbassyReq,
|
||||||
SetupEmbassyRes,
|
SetupEmbassyRes,
|
||||||
} from './api.service'
|
} from './api.service'
|
||||||
import { HttpService } from './http.service'
|
import { RPCEncryptedService } from '../rpc-encrypted.service'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class LiveApiService extends ApiService {
|
export class LiveApiService extends ApiService {
|
||||||
constructor(private readonly http: HttpService) {
|
constructor(
|
||||||
|
private readonly unencrypted: HttpService,
|
||||||
|
private readonly encrypted: RPCEncryptedService,
|
||||||
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ** UNENCRYPTED **
|
// ** UNENCRYPTED **
|
||||||
|
|
||||||
async getStatus() {
|
async getStatus() {
|
||||||
return this.http.rpcRequest<GetStatusRes>(
|
return this.unencrypted.rpcRequest<GetStatusRes>({
|
||||||
{
|
method: 'setup.status',
|
||||||
method: 'setup.status',
|
params: {},
|
||||||
params: {},
|
})
|
||||||
},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDrives() {
|
async getDrives() {
|
||||||
return this.http.rpcRequest<DiskListResponse>(
|
return this.unencrypted.rpcRequest<DiskListResponse>({
|
||||||
{
|
method: 'setup.disk.list',
|
||||||
method: 'setup.disk.list',
|
params: {},
|
||||||
params: {},
|
})
|
||||||
},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async set02XDrive(logicalname: string) {
|
async set02XDrive(logicalname: string) {
|
||||||
return this.http.rpcRequest<void>(
|
return this.unencrypted.rpcRequest<void>({
|
||||||
{
|
method: 'setup.recovery.v2.set',
|
||||||
method: 'setup.recovery.v2.set',
|
params: { logicalname },
|
||||||
params: { logicalname },
|
})
|
||||||
},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecoveryStatus() {
|
async getRecoveryStatus() {
|
||||||
return this.http.rpcRequest<RecoveryStatusRes>(
|
return this.unencrypted.rpcRequest<RecoveryStatusRes>({
|
||||||
{
|
method: 'setup.recovery.status',
|
||||||
method: 'setup.recovery.status',
|
params: {},
|
||||||
params: {},
|
})
|
||||||
},
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ** ENCRYPTED **
|
// ** ENCRYPTED **
|
||||||
|
|
||||||
async verifyCifs(source: CifsRecoverySource) {
|
async verifyCifs(source: CifsRecoverySource) {
|
||||||
source.path = source.path.replace('/\\/g', '/')
|
source.path = source.path.replace('/\\/g', '/')
|
||||||
return this.http.rpcRequest<EmbassyOSRecoveryInfo>({
|
return this.encrypted.rpcRequest<EmbassyOSRecoveryInfo>({
|
||||||
method: 'setup.cifs.verify',
|
method: 'setup.cifs.verify',
|
||||||
params: source as any,
|
params: source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyProductKey() {
|
async verifyProductKey() {
|
||||||
return this.http.rpcRequest<void>({
|
return this.encrypted.rpcRequest<void>({
|
||||||
method: 'echo',
|
method: 'echo',
|
||||||
params: { message: 'hello' },
|
params: { message: 'hello' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async importDrive(params: ImportDriveReq) {
|
async importDrive(params: ImportDriveReq) {
|
||||||
const res = await this.http.rpcRequest<SetupEmbassyRes>({
|
const res = await this.encrypted.rpcRequest<SetupEmbassyRes>({
|
||||||
method: 'setup.attach',
|
method: 'setup.attach',
|
||||||
params: params as any,
|
params,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -99,9 +91,9 @@ export class LiveApiService extends ApiService {
|
|||||||
].path.replace('/\\/g', '/')
|
].path.replace('/\\/g', '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await this.http.rpcRequest<SetupEmbassyRes>({
|
const res = await this.encrypted.rpcRequest<SetupEmbassyRes>({
|
||||||
method: 'setup.execute',
|
method: 'setup.execute',
|
||||||
params: setupInfo as any,
|
params: setupInfo,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -111,7 +103,7 @@ export class LiveApiService extends ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setupComplete() {
|
async setupComplete() {
|
||||||
const res = await this.http.rpcRequest<SetupEmbassyRes>({
|
const res = await this.encrypted.rpcRequest<SetupEmbassyRes>({
|
||||||
method: 'setup.complete',
|
method: 'setup.complete',
|
||||||
params: {},
|
params: {},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import * as aesjs from 'aes-js'
|
||||||
|
import * as pbkdf2 from 'pbkdf2'
|
||||||
|
import {
|
||||||
|
HttpError,
|
||||||
|
RpcError,
|
||||||
|
HttpService,
|
||||||
|
RPCOptions,
|
||||||
|
Method,
|
||||||
|
RPCResponse,
|
||||||
|
isRpcError,
|
||||||
|
} from '@start9labs/shared'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class RPCEncryptedService {
|
||||||
|
productKey?: string
|
||||||
|
|
||||||
|
constructor(private readonly http: HttpService) {}
|
||||||
|
|
||||||
|
async rpcRequest<T>(opts: Omit<RPCOptions, 'timeout'>): Promise<T> {
|
||||||
|
const encryptedBody = await AES_CTR.encryptPbkdf2(
|
||||||
|
this.productKey || '',
|
||||||
|
encodeUtf8(JSON.stringify(opts)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const res: RPCResponse<T> = await this.http
|
||||||
|
.httpRequest<ArrayBuffer>({
|
||||||
|
method: Method.POST,
|
||||||
|
url: this.http.relativeUrl,
|
||||||
|
body: encryptedBody.buffer,
|
||||||
|
responseType: 'arrayBuffer',
|
||||||
|
headers: {
|
||||||
|
'Content-Encoding': 'aesctr256',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(body => AES_CTR.decryptPbkdf2(this.productKey || '', body))
|
||||||
|
.then(res => JSON.parse(res))
|
||||||
|
.catch(e => {
|
||||||
|
if (!e.status && !e.statusText) {
|
||||||
|
throw new EncryptionError()
|
||||||
|
} else {
|
||||||
|
throw new HttpError(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (isRpcError(res)) throw new RpcError(res.error)
|
||||||
|
return res.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EncryptionError {
|
||||||
|
readonly code = null
|
||||||
|
readonly message = 'Invalid Key'
|
||||||
|
readonly details = null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AES_CTR = {
|
||||||
|
encryptPbkdf2: (
|
||||||
|
secretKey: string,
|
||||||
|
messageBuffer: Uint8Array,
|
||||||
|
) => Promise<Uint8Array>
|
||||||
|
decryptPbkdf2: (secretKey: string, arr: ArrayBuffer) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AES_CTR: AES_CTR = {
|
||||||
|
encryptPbkdf2: async (secretKey: string, messageBuffer: Uint8Array) => {
|
||||||
|
const salt = window.crypto.getRandomValues(new Uint8Array(16))
|
||||||
|
const counter = window.crypto.getRandomValues(new Uint8Array(16))
|
||||||
|
|
||||||
|
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
|
||||||
|
|
||||||
|
const aesCtr = new aesjs.ModeOfOperation.ctr(
|
||||||
|
key,
|
||||||
|
new aesjs.Counter(counter),
|
||||||
|
)
|
||||||
|
const encryptedBytes = aesCtr.encrypt(messageBuffer)
|
||||||
|
return new Uint8Array([...counter, ...salt, ...encryptedBytes])
|
||||||
|
},
|
||||||
|
decryptPbkdf2: async (secretKey: string, arr: ArrayBuffer) => {
|
||||||
|
const buff = new Uint8Array(arr)
|
||||||
|
const counter = buff.slice(0, 16)
|
||||||
|
const salt = buff.slice(16, 32)
|
||||||
|
|
||||||
|
const cipher = buff.slice(32)
|
||||||
|
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
|
||||||
|
|
||||||
|
const aesCtr = new aesjs.ModeOfOperation.ctr(
|
||||||
|
key,
|
||||||
|
new aesjs.Counter(counter),
|
||||||
|
)
|
||||||
|
const decryptedBytes = aesCtr.decrypt(cipher)
|
||||||
|
|
||||||
|
return aesjs.utils.utf8.fromBytes(decryptedBytes)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeUtf8(str: string): Uint8Array {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
return encoder.encode(str)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<ion-grid class="full-height">
|
<ion-grid class="full-height">
|
||||||
<ion-row class="ion-align-items-center ion-text-center full-height">
|
<ion-row class="ion-align-items-center ion-text-center full-height">
|
||||||
<ion-col>
|
<ion-col>
|
||||||
<ion-spinner name="lines" color="warning"></ion-spinner>
|
<ion-spinner color="warning"></ion-spinner>
|
||||||
<p>{{ text }}</p>
|
<p>{{ text }}</p>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
</ion-row>
|
</ion-row>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export * from './classes/http-error'
|
|||||||
export * from './classes/rpc-error'
|
export * from './classes/rpc-error'
|
||||||
|
|
||||||
export * from './components/markdown/markdown.component'
|
export * from './components/markdown/markdown.component'
|
||||||
export * from './components/markdown/markdown.module'
|
export * from './components/markdown/markdown.component.module'
|
||||||
export * from './components/text-spinner/text-spinner.component.module'
|
|
||||||
export * from './components/text-spinner/text-spinner.component'
|
export * from './components/text-spinner/text-spinner.component'
|
||||||
|
export * from './components/text-spinner/text-spinner.component.module'
|
||||||
|
|
||||||
export * from './directives/element/element.directive'
|
export * from './directives/element/element.directive'
|
||||||
export * from './directives/element/element.module'
|
export * from './directives/element/element.module'
|
||||||
@@ -27,13 +27,17 @@ export * from './pipes/unit-conversion/unit-conversion.module'
|
|||||||
export * from './pipes/unit-conversion/unit-conversion.pipe'
|
export * from './pipes/unit-conversion/unit-conversion.pipe'
|
||||||
|
|
||||||
export * from './services/destroy.service'
|
export * from './services/destroy.service'
|
||||||
|
export * from './services/download-html.service'
|
||||||
export * from './services/emver.service'
|
export * from './services/emver.service'
|
||||||
export * from './services/error-toast.service'
|
export * from './services/error-toast.service'
|
||||||
|
export * from './services/http.service'
|
||||||
|
|
||||||
|
export * from './types/api'
|
||||||
export * from './types/rpc-error-details'
|
export * from './types/rpc-error-details'
|
||||||
export * from './types/url'
|
export * from './types/url'
|
||||||
export * from './types/workspace-config'
|
export * from './types/workspace-config'
|
||||||
|
|
||||||
|
export * from './util/copy-to-clipboard'
|
||||||
export * from './util/get-pkg-id'
|
export * from './util/get-pkg-id'
|
||||||
export * from './util/misc.util'
|
export * from './util/misc.util'
|
||||||
export * from './util/to-local-iso-string'
|
export * from './util/to-local-iso-string'
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common'
|
||||||
|
import { Inject, Injectable } from '@angular/core'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DownloadHTMLService {
|
||||||
|
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
|
||||||
|
|
||||||
|
async download(filename: string, html: string, styleObj = {}) {
|
||||||
|
const entries = Object.entries(styleObj)
|
||||||
|
.map(([k, v]) => `${k}:${v}`)
|
||||||
|
.join(';')
|
||||||
|
const styleString = entries ? `<style>html{${entries}}></style>` : ''
|
||||||
|
|
||||||
|
console.log('STYLES', styleString)
|
||||||
|
html = styleString + html
|
||||||
|
|
||||||
|
const elem = this.document.createElement('a')
|
||||||
|
elem.setAttribute(
|
||||||
|
'href',
|
||||||
|
'data:text/plain;charset=utf-8,' + encodeURIComponent(html),
|
||||||
|
)
|
||||||
|
elem.setAttribute('download', filename)
|
||||||
|
elem.style.display = 'none'
|
||||||
|
|
||||||
|
this.document.body.appendChild(elem)
|
||||||
|
elem.click()
|
||||||
|
this.document.body.removeChild(elem)
|
||||||
|
}
|
||||||
|
}
|
||||||
199
frontend/projects/shared/src/services/http.service.ts
Normal file
199
frontend/projects/shared/src/services/http.service.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { Inject, Injectable } from '@angular/core'
|
||||||
|
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
|
||||||
|
import { HttpError, RpcError, WorkspaceConfig } from '@start9labs/shared'
|
||||||
|
import {
|
||||||
|
firstValueFrom,
|
||||||
|
from,
|
||||||
|
interval,
|
||||||
|
lastValueFrom,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
race,
|
||||||
|
take,
|
||||||
|
} from 'rxjs'
|
||||||
|
import { DOCUMENT } from '@angular/common'
|
||||||
|
|
||||||
|
const {
|
||||||
|
ui: { api },
|
||||||
|
} = require('../../../../config.json') as WorkspaceConfig
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class HttpService {
|
||||||
|
relativeUrl = `/${api.url}/${api.version}`
|
||||||
|
private fullUrl: string
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DOCUMENT) private readonly document: Document,
|
||||||
|
private readonly http: HttpClient,
|
||||||
|
) {
|
||||||
|
const { protocol, hostname, port } = this.document.location
|
||||||
|
this.fullUrl = `${protocol}//${hostname}:${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
||||||
|
const { method, params, timeout } = opts
|
||||||
|
|
||||||
|
const res = await this.httpRequest<RPCResponse<T>>({
|
||||||
|
method: Method.POST,
|
||||||
|
url: this.relativeUrl,
|
||||||
|
body: { method, params },
|
||||||
|
timeout,
|
||||||
|
})
|
||||||
|
if (isRpcError(res)) throw new RpcError(res.error)
|
||||||
|
return res.result
|
||||||
|
}
|
||||||
|
|
||||||
|
async httpRequest<T>(opts: HttpOptions): Promise<T> {
|
||||||
|
let { method, url, headers, body, responseType, timeout } = opts
|
||||||
|
|
||||||
|
url = opts.url.startsWith('/') ? this.fullUrl + url : url
|
||||||
|
|
||||||
|
const { params } = opts
|
||||||
|
if (hasParams(params)) {
|
||||||
|
Object.keys(params).forEach(key => {
|
||||||
|
if (params[key] === undefined) {
|
||||||
|
delete params[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: HttpAngularOptions = {
|
||||||
|
observe: 'response',
|
||||||
|
withCredentials: true,
|
||||||
|
headers,
|
||||||
|
params,
|
||||||
|
responseType: responseType || 'json',
|
||||||
|
}
|
||||||
|
|
||||||
|
let req: Observable<{ body: T }>
|
||||||
|
if (method === Method.GET) {
|
||||||
|
req = this.http.get(url, options as any) as any
|
||||||
|
} else {
|
||||||
|
req = this.http.post(url, body, options as any) as any
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstValueFrom(timeout ? withTimeout(req, timeout) : req)
|
||||||
|
.then(res => res.body)
|
||||||
|
.catch(e => {
|
||||||
|
throw new HttpError(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ** RPC types **
|
||||||
|
|
||||||
|
interface RPCBase {
|
||||||
|
jsonrpc: '2.0'
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RPCRequest<T> extends RPCBase {
|
||||||
|
method: string
|
||||||
|
params?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RPCSuccess<T> extends RPCBase {
|
||||||
|
result: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RPCError extends RPCBase {
|
||||||
|
error: {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data?:
|
||||||
|
| {
|
||||||
|
details: string
|
||||||
|
}
|
||||||
|
| string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RPCResponse<T> = RPCSuccess<T> | RPCError
|
||||||
|
|
||||||
|
export interface RPCOptions {
|
||||||
|
method: string
|
||||||
|
params: {
|
||||||
|
[param: string]:
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| object
|
||||||
|
| string[]
|
||||||
|
| number[]
|
||||||
|
| null
|
||||||
|
}
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRpcError<Error, Result>(
|
||||||
|
arg: { error: Error } | { result: Result },
|
||||||
|
): arg is { error: Error } {
|
||||||
|
return (arg as any).error !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ** HTTP types **
|
||||||
|
|
||||||
|
export enum Method {
|
||||||
|
GET = 'GET',
|
||||||
|
POST = 'POST',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpOptions {
|
||||||
|
method: Method
|
||||||
|
url: string
|
||||||
|
headers?:
|
||||||
|
| HttpHeaders
|
||||||
|
| {
|
||||||
|
[header: string]: string | string[]
|
||||||
|
}
|
||||||
|
params?:
|
||||||
|
| HttpParams
|
||||||
|
| {
|
||||||
|
[param: string]: string | string[]
|
||||||
|
}
|
||||||
|
responseType?: 'json' | 'text' | 'arrayBuffer'
|
||||||
|
body?: any
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpAngularOptions {
|
||||||
|
observe: 'response'
|
||||||
|
withCredentials: true
|
||||||
|
headers?:
|
||||||
|
| HttpHeaders
|
||||||
|
| {
|
||||||
|
[header: string]: string | string[]
|
||||||
|
}
|
||||||
|
params?:
|
||||||
|
| HttpParams
|
||||||
|
| {
|
||||||
|
[param: string]: string | string[]
|
||||||
|
}
|
||||||
|
responseType?: 'json' | 'text' | 'arrayBuffer'
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasParams(
|
||||||
|
params?: HttpOptions['params'],
|
||||||
|
): params is Record<string, string | string[]> {
|
||||||
|
return !!params
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<U>(req: Observable<U>, timeout: number): Observable<U> {
|
||||||
|
return race(
|
||||||
|
from(lastValueFrom(req)), // this guarantees it only emits on completion, intermediary emissions are suppressed.
|
||||||
|
interval(timeout).pipe(
|
||||||
|
take(1),
|
||||||
|
map(() => {
|
||||||
|
throw new Error('timeout')
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestError {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
details: string
|
||||||
|
}
|
||||||
16
frontend/projects/shared/src/types/api.ts
Normal file
16
frontend/projects/shared/src/types/api.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type ServerLogsReq = {
|
||||||
|
before: boolean
|
||||||
|
cursor?: string
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogsRes = {
|
||||||
|
entries: Log[]
|
||||||
|
'start-cursor'?: string
|
||||||
|
'end-cursor'?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Log {
|
||||||
|
timestamp: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
19
frontend/projects/shared/src/util/copy-to-clipboard.ts
Normal file
19
frontend/projects/shared/src/util/copy-to-clipboard.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export async function copyToClipboard(str: string): Promise<boolean> {
|
||||||
|
if (window.isSecureContext) {
|
||||||
|
return navigator.clipboard
|
||||||
|
.writeText(str)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = str
|
||||||
|
el.setAttribute('readonly', '')
|
||||||
|
el.style.position = 'absolute'
|
||||||
|
el.style.left = '-9999px'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
const didCopy = document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
return didCopy
|
||||||
|
}
|
||||||
@@ -6,8 +6,7 @@
|
|||||||
"outDir": "../../out-tsc/lib",
|
"outDir": "../../out-tsc/lib",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"inlineSources": true,
|
"inlineSources": true
|
||||||
"types": []
|
|
||||||
},
|
},
|
||||||
"exclude": ["src/test.ts", "**/*.spec.ts"]
|
"exclude": ["src/test.ts", "**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<a class="logo ion-padding" target="_blank" rel="noreferrer" [href]="href">
|
<a class="logo ion-padding" routerLink="/services">
|
||||||
<img alt="Start9" src="assets/img/logo.png" />
|
<img alt="Start9" src="assets/img/logo.png" />
|
||||||
</a>
|
</a>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||||
import { AlertController } from '@ionic/angular'
|
import { AlertController } from '@ionic/angular'
|
||||||
import { ConfigService } from '../../services/config.service'
|
|
||||||
import { LocalStorageService } from '../../services/local-storage.service'
|
import { LocalStorageService } from '../../services/local-storage.service'
|
||||||
import { EOSService } from '../../services/eos.service'
|
import { EOSService } from '../../services/eos.service'
|
||||||
import { ApiService } from '../../services/api/embassy-api.service'
|
import { ApiService } from '../../services/api/embassy-api.service'
|
||||||
@@ -62,7 +61,6 @@ export class MenuComponent {
|
|||||||
.pipe(map(pkgs => pkgs.length))
|
.pipe(map(pkgs => pkgs.length))
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
|
||||||
private readonly alertCtrl: AlertController,
|
private readonly alertCtrl: AlertController,
|
||||||
private readonly embassyApi: ApiService,
|
private readonly embassyApi: ApiService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
@@ -73,12 +71,6 @@ export class MenuComponent {
|
|||||||
private readonly marketplaceService: MarketplaceService,
|
private readonly marketplaceService: MarketplaceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get href(): string {
|
|
||||||
return this.config.isTor()
|
|
||||||
? 'http://privacy34kn4ez3y3nijweec6w4g54i3g54sdv7r5mr6soma3w4begyd.onion'
|
|
||||||
: 'https://start9.com'
|
|
||||||
}
|
|
||||||
|
|
||||||
async presentAlertLogout() {
|
async presentAlertLogout() {
|
||||||
const alert = await this.alertCtrl.create({
|
const alert = await this.alertCtrl.create({
|
||||||
header: 'Caution',
|
header: 'Caution',
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-buttons slot="start">
|
||||||
|
<ion-back-button [defaultHref]="defaultBack"></ion-back-button>
|
||||||
|
</ion-buttons>
|
||||||
|
<ion-title>{{ title }}</ion-title>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content
|
||||||
|
[scrollEvents]="true"
|
||||||
|
(ionScroll)="handleScroll($event)"
|
||||||
|
(ionScrollEnd)="handleScrollEnd()"
|
||||||
|
class="ion-padding"
|
||||||
|
>
|
||||||
|
<ion-infinite-scroll
|
||||||
|
id="scroller"
|
||||||
|
*ngIf="!loading && needInfinite"
|
||||||
|
position="top"
|
||||||
|
threshold="0"
|
||||||
|
(ionInfinite)="doInfinite($event)"
|
||||||
|
>
|
||||||
|
<ion-infinite-scroll-content
|
||||||
|
loadingSpinner="lines"
|
||||||
|
></ion-infinite-scroll-content>
|
||||||
|
</ion-infinite-scroll>
|
||||||
|
|
||||||
|
<text-spinner *ngIf="loading" text="Loading Logs"></text-spinner>
|
||||||
|
|
||||||
|
<div id="container">
|
||||||
|
<div id="template"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="!loading">
|
||||||
|
<div id="bottom-div"></div>
|
||||||
|
<div *ngIf="websocketFail" class="ion-text-center ion-padding">
|
||||||
|
<ion-text color="warning"> Websocket failed.... </ion-text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
[ngStyle]="{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '96px',
|
||||||
|
right: isOnBottom ? '-52px' : '30px',
|
||||||
|
'background-color': 'var(--ion-color-medium)',
|
||||||
|
'border-radius': '100%',
|
||||||
|
transition: 'right 0.25s ease-out'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ion-button
|
||||||
|
style="
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
--padding-start: 0px;
|
||||||
|
--padding-end: 0px;
|
||||||
|
--border-radius: 100%;
|
||||||
|
"
|
||||||
|
color="dark"
|
||||||
|
(click)="scrollToBottom(); autoScroll = true"
|
||||||
|
strong
|
||||||
|
>
|
||||||
|
<ion-icon name="chevron-down"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ion-content>
|
||||||
|
|
||||||
|
<ion-footer>
|
||||||
|
<ion-toolbar>
|
||||||
|
<div class="inline ion-padding-start">
|
||||||
|
<ion-checkbox [(ngModel)]="autoScroll" color="dark"></ion-checkbox>
|
||||||
|
<p class="ion-padding-start">Autoscroll</p>
|
||||||
|
</div>
|
||||||
|
<ion-button
|
||||||
|
*ngIf="!loading"
|
||||||
|
slot="end"
|
||||||
|
class="ion-padding-end"
|
||||||
|
fill="clear"
|
||||||
|
strong
|
||||||
|
(click)="download()"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-footer>
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { IonicModule } from '@ionic/angular'
|
import { IonicModule } from '@ionic/angular'
|
||||||
import { LogsPage } from './logs.page'
|
import { LogsComponent } from './logs.component'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
import { TextSpinnerComponentModule } from '@start9labs/shared'
|
import { TextSpinnerComponentModule } from '@start9labs/shared'
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [LogsPage],
|
declarations: [LogsComponent],
|
||||||
imports: [CommonModule, IonicModule, TextSpinnerComponentModule],
|
imports: [CommonModule, IonicModule, TextSpinnerComponentModule, FormsModule],
|
||||||
exports: [LogsPage],
|
exports: [LogsComponent],
|
||||||
})
|
})
|
||||||
export class LogsPageModule {}
|
export class LogsComponentModule {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#container {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
226
frontend/projects/ui/src/app/components/logs/logs.component.ts
Normal file
226
frontend/projects/ui/src/app/components/logs/logs.component.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common'
|
||||||
|
import { Component, Inject, Input, ViewChild } from '@angular/core'
|
||||||
|
import { IonContent, LoadingController } from '@ionic/angular'
|
||||||
|
import { map, takeUntil, timer } from 'rxjs'
|
||||||
|
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||||
|
import {
|
||||||
|
LogsRes,
|
||||||
|
ServerLogsReq,
|
||||||
|
DestroyService,
|
||||||
|
ErrorToastService,
|
||||||
|
toLocalIsoString,
|
||||||
|
Log,
|
||||||
|
DownloadHTMLService,
|
||||||
|
} from '@start9labs/shared'
|
||||||
|
import { RR } from 'src/app/services/api/api.types'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
|
||||||
|
var Convert = require('ansi-to-html')
|
||||||
|
var convert = new Convert({
|
||||||
|
newline: true,
|
||||||
|
bg: 'transparent',
|
||||||
|
colors: {
|
||||||
|
4: 'Cyan',
|
||||||
|
},
|
||||||
|
escapeXML: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'logs',
|
||||||
|
templateUrl: './logs.component.html',
|
||||||
|
styleUrls: ['./logs.component.scss'],
|
||||||
|
providers: [DestroyService, DownloadHTMLService],
|
||||||
|
})
|
||||||
|
export class LogsComponent {
|
||||||
|
@ViewChild(IonContent)
|
||||||
|
private content?: IonContent
|
||||||
|
|
||||||
|
@Input() followLogs!: (
|
||||||
|
params: RR.FollowServerLogsReq,
|
||||||
|
) => Promise<RR.FollowServerLogsRes>
|
||||||
|
@Input() fetchLogs!: (params: ServerLogsReq) => Promise<LogsRes>
|
||||||
|
@Input() defaultBack!: string
|
||||||
|
@Input() title!: string
|
||||||
|
|
||||||
|
loading = true
|
||||||
|
needInfinite = true
|
||||||
|
startCursor?: string
|
||||||
|
isOnBottom = true
|
||||||
|
autoScroll = true
|
||||||
|
websocketFail = false
|
||||||
|
limit = 200
|
||||||
|
toProcess: Log[] = []
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DOCUMENT) private readonly document: Document,
|
||||||
|
private readonly errToast: ErrorToastService,
|
||||||
|
private readonly destroy$: DestroyService,
|
||||||
|
private readonly api: ApiService,
|
||||||
|
private readonly loadingCtrl: LoadingController,
|
||||||
|
private readonly downloadHtml: DownloadHTMLService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
try {
|
||||||
|
const { 'start-cursor': startCursor, guid } = await this.followLogs({
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.startCursor = startCursor
|
||||||
|
|
||||||
|
const host = this.document.location.host
|
||||||
|
const protocol =
|
||||||
|
this.document.location.protocol === 'http:' ? 'ws' : 'wss'
|
||||||
|
|
||||||
|
const config: WebSocketSubjectConfig<Log> = {
|
||||||
|
url: `${protocol}://${host}/ws/rpc/${guid}`,
|
||||||
|
openObserver: {
|
||||||
|
next: () => {
|
||||||
|
console.log('**** LOGS WEBSOCKET OPEN ****')
|
||||||
|
this.websocketFail = false
|
||||||
|
this.processJob()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
this.api
|
||||||
|
.openLogsWebsocket$(config)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: msg => {
|
||||||
|
this.toProcess.push(msg)
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.websocketFail = true
|
||||||
|
if (this.isOnBottom) this.scrollToBottom()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errToast.present(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async doInfinite(e: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await this.fetchLogs({
|
||||||
|
cursor: this.startCursor,
|
||||||
|
before: true,
|
||||||
|
limit: this.limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.processRes(res)
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errToast.present(e)
|
||||||
|
} finally {
|
||||||
|
e.target.complete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll(e: any) {
|
||||||
|
if (e.detail.deltaY < 0) this.autoScroll = false
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScrollEnd() {
|
||||||
|
const bottomDiv = document.getElementById('bottom-div')
|
||||||
|
this.isOnBottom =
|
||||||
|
!!bottomDiv &&
|
||||||
|
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
this.content?.scrollToBottom(250)
|
||||||
|
}
|
||||||
|
|
||||||
|
async download() {
|
||||||
|
const loader = await this.loadingCtrl.create({
|
||||||
|
message: 'Processing 10,000 logs...',
|
||||||
|
})
|
||||||
|
await loader.present()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { entries } = await this.fetchLogs({
|
||||||
|
before: true,
|
||||||
|
limit: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
'background-color': '#222428',
|
||||||
|
color: '#e0e0e0',
|
||||||
|
'font-family': 'monospace',
|
||||||
|
}
|
||||||
|
const html = this.convertToAnsi(entries)
|
||||||
|
|
||||||
|
this.downloadHtml.download('logs.html', html, styles)
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errToast.present(e)
|
||||||
|
} finally {
|
||||||
|
loader.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processJob() {
|
||||||
|
timer(0, 500)
|
||||||
|
.pipe(
|
||||||
|
map((_, index) => index),
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
)
|
||||||
|
.subscribe(index => {
|
||||||
|
this.processRes({ entries: this.toProcess })
|
||||||
|
this.toProcess = []
|
||||||
|
if (index === 0) this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private processRes(res: LogsRes) {
|
||||||
|
const { entries, 'start-cursor': startCursor } = res
|
||||||
|
|
||||||
|
if (!entries.length) return
|
||||||
|
|
||||||
|
const container = document.getElementById('container')
|
||||||
|
const newLogs = document.getElementById('template')?.cloneNode()
|
||||||
|
|
||||||
|
if (!(newLogs instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
newLogs.innerHTML = this.convertToAnsi(entries)
|
||||||
|
|
||||||
|
// if respone contains startCursor, it means we are scrolling backwards
|
||||||
|
if (startCursor) {
|
||||||
|
this.startCursor = startCursor
|
||||||
|
|
||||||
|
const beforeContainerHeight = container?.scrollHeight || 0
|
||||||
|
container?.prepend(newLogs)
|
||||||
|
const afterContainerHeight = container?.scrollHeight || 0
|
||||||
|
|
||||||
|
// scroll down
|
||||||
|
setTimeout(() => {
|
||||||
|
this.content?.scrollToPoint(
|
||||||
|
0,
|
||||||
|
afterContainerHeight - beforeContainerHeight,
|
||||||
|
)
|
||||||
|
}, 25)
|
||||||
|
|
||||||
|
if (entries.length < this.limit) {
|
||||||
|
this.needInfinite = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
container?.append(newLogs)
|
||||||
|
if (this.autoScroll) {
|
||||||
|
// scroll to bottom
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scrollToBottom()
|
||||||
|
}, 25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertToAnsi(entries: Log[]) {
|
||||||
|
return entries
|
||||||
|
.map(
|
||||||
|
entry =>
|
||||||
|
`<span style="color: #FFF; font-weight: bold;">${toLocalIsoString(
|
||||||
|
new Date(entry.timestamp),
|
||||||
|
)}</span> ${convert.toHtml(entry.message)}`,
|
||||||
|
)
|
||||||
|
.join('<br />')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<ion-content
|
|
||||||
[scrollEvents]="true"
|
|
||||||
(ionScroll)="scrollEvent()"
|
|
||||||
style="height: 100%"
|
|
||||||
class="ion-padding"
|
|
||||||
>
|
|
||||||
<ion-infinite-scroll
|
|
||||||
id="scroller"
|
|
||||||
*ngIf="!loading && needInfinite"
|
|
||||||
position="top"
|
|
||||||
threshold="0"
|
|
||||||
(ionInfinite)="doInfinite($event)"
|
|
||||||
>
|
|
||||||
<ion-infinite-scroll-content
|
|
||||||
loadingSpinner="lines"
|
|
||||||
></ion-infinite-scroll-content>
|
|
||||||
</ion-infinite-scroll>
|
|
||||||
|
|
||||||
<text-spinner *ngIf="loading" text="Loading Logs"></text-spinner>
|
|
||||||
|
|
||||||
<div id="container">
|
|
||||||
<div
|
|
||||||
id="template"
|
|
||||||
style="white-space: pre-line; font-family: monospace"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div id="button-div" *ngIf="!loading" style="width: 100%; text-align: center">
|
|
||||||
<ion-button *ngIf="!loadingNext" (click)="getNext()" strong color="dark">
|
|
||||||
Load More
|
|
||||||
<ion-icon slot="end" name="refresh"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
<ion-spinner *ngIf="loadingNext" name="lines" color="warning"></ion-spinner>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
*ngIf="!loading"
|
|
||||||
[ngStyle]="{
|
|
||||||
'position': 'fixed',
|
|
||||||
'bottom': '36px',
|
|
||||||
'right': isOnBottom ? '-52px' : '36px',
|
|
||||||
'background-color': 'var(--ion-color-medium)',
|
|
||||||
'border-radius': '100%',
|
|
||||||
'transition': 'right 0.4s ease-out'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ion-button
|
|
||||||
style="
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
--padding-start: 0px;
|
|
||||||
--padding-end: 0px;
|
|
||||||
--border-radius: 100%;
|
|
||||||
"
|
|
||||||
color="dark"
|
|
||||||
(click)="scrollToBottom()"
|
|
||||||
strong
|
|
||||||
>
|
|
||||||
<ion-icon name="chevron-down"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
</div>
|
|
||||||
</ion-content>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#container {
|
|
||||||
padding-bottom: 16px;
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { formatDate } from '@angular/common'
|
|
||||||
import { Component, Input, ViewChild } from '@angular/core'
|
|
||||||
import { IonContent } from '@ionic/angular'
|
|
||||||
import { ErrorToastService, toLocalIsoString } from '@start9labs/shared'
|
|
||||||
import { RR } from 'src/app/services/api/api.types'
|
|
||||||
var Convert = require('ansi-to-html')
|
|
||||||
var convert = new Convert({
|
|
||||||
bg: 'transparent',
|
|
||||||
colors: {
|
|
||||||
4: 'Cyan',
|
|
||||||
},
|
|
||||||
escapeXML: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'logs',
|
|
||||||
templateUrl: './logs.page.html',
|
|
||||||
styleUrls: ['./logs.page.scss'],
|
|
||||||
})
|
|
||||||
export class LogsPage {
|
|
||||||
@ViewChild(IonContent)
|
|
||||||
private content?: IonContent
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
fetchLogs!: (params: {
|
|
||||||
before_flag?: boolean
|
|
||||||
limit?: number
|
|
||||||
cursor?: string
|
|
||||||
}) => Promise<RR.LogsRes>
|
|
||||||
|
|
||||||
loading = true
|
|
||||||
loadingNext = false
|
|
||||||
needInfinite = true
|
|
||||||
startCursor?: string
|
|
||||||
endCursor?: string
|
|
||||||
limit = 400
|
|
||||||
isOnBottom = true
|
|
||||||
|
|
||||||
constructor(private readonly errToast: ErrorToastService) {}
|
|
||||||
|
|
||||||
async ngOnInit() {
|
|
||||||
await this.getPrior()
|
|
||||||
this.loading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNext() {
|
|
||||||
this.loadingNext = true
|
|
||||||
const logs = await this.fetch(false)
|
|
||||||
if (!logs?.length) return (this.loadingNext = false)
|
|
||||||
|
|
||||||
const container = document.getElementById('container')
|
|
||||||
const newLogs = document.getElementById('template')?.cloneNode(true)
|
|
||||||
|
|
||||||
if (!(newLogs instanceof HTMLElement)) return
|
|
||||||
|
|
||||||
newLogs.innerHTML =
|
|
||||||
logs
|
|
||||||
.map(
|
|
||||||
l =>
|
|
||||||
`<b>${toLocalIsoString(new Date(l.timestamp))}</b> ${convert.toHtml(
|
|
||||||
l.message,
|
|
||||||
)}`,
|
|
||||||
)
|
|
||||||
.join('\n') + (logs.length ? '\n' : '')
|
|
||||||
container?.append(newLogs)
|
|
||||||
this.loadingNext = false
|
|
||||||
this.scrollEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
async doInfinite(e: any): Promise<void> {
|
|
||||||
await this.getPrior()
|
|
||||||
e.target.complete()
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollEvent() {
|
|
||||||
const buttonDiv = document.getElementById('button-div')
|
|
||||||
this.isOnBottom =
|
|
||||||
!!buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollToBottom() {
|
|
||||||
this.content?.scrollToBottom(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getPrior() {
|
|
||||||
// get logs
|
|
||||||
const logs = await this.fetch()
|
|
||||||
if (!logs?.length) return
|
|
||||||
|
|
||||||
const container = document.getElementById('container')
|
|
||||||
const beforeContainerHeight = container?.scrollHeight || 0
|
|
||||||
const newLogs = document.getElementById('template')?.cloneNode(true)
|
|
||||||
|
|
||||||
if (!(newLogs instanceof HTMLElement)) return
|
|
||||||
|
|
||||||
newLogs.innerHTML =
|
|
||||||
logs
|
|
||||||
.map(
|
|
||||||
l =>
|
|
||||||
`<b>${toLocalIsoString(new Date(l.timestamp))}</b> ${convert.toHtml(
|
|
||||||
l.message,
|
|
||||||
)}`,
|
|
||||||
)
|
|
||||||
.join('\n') + (logs.length ? '\n' : '')
|
|
||||||
container?.prepend(newLogs)
|
|
||||||
const afterContainerHeight = container?.scrollHeight || 0
|
|
||||||
|
|
||||||
// scroll down
|
|
||||||
scrollBy(0, afterContainerHeight - beforeContainerHeight)
|
|
||||||
this.content?.scrollToPoint(0, afterContainerHeight - beforeContainerHeight)
|
|
||||||
|
|
||||||
if (logs.length < this.limit) {
|
|
||||||
this.needInfinite = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetch(isBefore: boolean = true) {
|
|
||||||
try {
|
|
||||||
const cursor = isBefore ? this.startCursor : this.endCursor
|
|
||||||
const logsRes = await this.fetchLogs({
|
|
||||||
cursor,
|
|
||||||
before_flag: !!cursor ? isBefore : undefined,
|
|
||||||
limit: this.limit,
|
|
||||||
})
|
|
||||||
|
|
||||||
if ((isBefore || this.startCursor) && logsRes['start-cursor']) {
|
|
||||||
this.startCursor = logsRes['start-cursor']
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((!isBefore || !this.endCursor) && logsRes['end-cursor']) {
|
|
||||||
this.endCursor = logsRes['end-cursor']
|
|
||||||
}
|
|
||||||
|
|
||||||
return logsRes.entries
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errToast.present(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
<ion-header>
|
<ion-header>
|
||||||
<ion-toolbar>
|
<ion-toolbar>
|
||||||
<ion-buttons slot="start">
|
<ion-title>Execution Complete</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
<ion-button (click)="dismiss()" class="enter-click">
|
<ion-button (click)="dismiss()" class="enter-click">
|
||||||
<ion-icon name="close"></ion-icon>
|
<ion-icon name="close"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
<ion-title>Execution Complete</ion-title>
|
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
||||||
<ion-content>
|
<ion-content class="ion-padding">
|
||||||
<ion-item>
|
<h2 class="ion-padding">{{ actionRes.message }}</h2>
|
||||||
<ion-label>
|
|
||||||
<h2>{{ actionRes.message }}</h2>
|
|
||||||
</ion-label>
|
|
||||||
</ion-item>
|
|
||||||
|
|
||||||
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 64px 0">
|
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 48px 0">
|
||||||
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
|
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
|
||||||
<qr-code [value]="actionRes.value" size="240"></qr-code>
|
<qr-code [value]="actionRes.value" size="240"></qr-code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, Input } from '@angular/core'
|
import { Component, Input } from '@angular/core'
|
||||||
import { ModalController, ToastController } from '@ionic/angular'
|
import { ModalController, ToastController } from '@ionic/angular'
|
||||||
import { ActionResponse } from 'src/app/services/api/api.types'
|
import { ActionResponse } from 'src/app/services/api/api.types'
|
||||||
import { copyToClipboard } from 'src/app/util/web.util'
|
import { copyToClipboard } from '@start9labs/shared'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'action-success',
|
selector: 'action-success',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<ng-template #noError>
|
<ng-template #noError>
|
||||||
<ng-container *ngIf="hasConfig && !pkg.installed?.status?.configured">
|
<ng-container *ngIf="configForm && !pkg.installed?.status?.configured">
|
||||||
<ng-container *ngIf="!original; else hasOriginal">
|
<ng-container *ngIf="!original; else hasOriginal">
|
||||||
<h2
|
<h2
|
||||||
*ngIf="!configForm.dirty"
|
*ngIf="!configForm.dirty"
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<!-- no config -->
|
<!-- no config -->
|
||||||
<ion-item *ngIf="!hasConfig">
|
<ion-item *ngIf="!configForm">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p>
|
<p>
|
||||||
No config options for {{ pkg.manifest.title }} {{
|
No config options for {{ pkg.manifest.title }} {{
|
||||||
@@ -91,7 +91,11 @@
|
|||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
<!-- has config -->
|
<!-- has config -->
|
||||||
<form *ngIf="hasConfig" [formGroup]="configForm" novalidate>
|
<form
|
||||||
|
*ngIf="configForm && configSpec"
|
||||||
|
[formGroup]="configForm"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
<form-object
|
<form-object
|
||||||
[objectSpec]="configSpec"
|
[objectSpec]="configSpec"
|
||||||
[formGroup]="configForm"
|
[formGroup]="configForm"
|
||||||
@@ -107,7 +111,7 @@
|
|||||||
<ion-footer>
|
<ion-footer>
|
||||||
<ion-toolbar>
|
<ion-toolbar>
|
||||||
<ng-container *ngIf="!loading && !loadingError">
|
<ng-container *ngIf="!loading && !loadingError">
|
||||||
<ion-buttons *ngIf="hasConfig" slot="start" class="ion-padding-start">
|
<ion-buttons *ngIf="configForm" slot="start" class="ion-padding-start">
|
||||||
<ion-button fill="clear" (click)="resetDefaults()">
|
<ion-button fill="clear" (click)="resetDefaults()">
|
||||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||||
Reset Defaults
|
Reset Defaults
|
||||||
@@ -115,7 +119,7 @@
|
|||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
<ion-buttons slot="end" class="ion-padding-end">
|
<ion-buttons slot="end" class="ion-padding-end">
|
||||||
<ion-button
|
<ion-button
|
||||||
*ngIf="hasConfig"
|
*ngIf="configForm"
|
||||||
fill="solid"
|
fill="solid"
|
||||||
color="primary"
|
color="primary"
|
||||||
[disabled]="saving"
|
[disabled]="saving"
|
||||||
@@ -126,7 +130,7 @@
|
|||||||
Save
|
Save
|
||||||
</ion-button>
|
</ion-button>
|
||||||
<ion-button
|
<ion-button
|
||||||
*ngIf="!hasConfig"
|
*ngIf="!configForm"
|
||||||
fill="solid"
|
fill="solid"
|
||||||
color="dark"
|
color="dark"
|
||||||
(click)="dismiss()"
|
(click)="dismiss()"
|
||||||
|
|||||||
@@ -34,19 +34,18 @@ import { Breakages } from 'src/app/services/api/api.types'
|
|||||||
export class AppConfigPage {
|
export class AppConfigPage {
|
||||||
@Input() pkgId!: string
|
@Input() pkgId!: string
|
||||||
|
|
||||||
@Input()
|
@Input() dependentInfo?: DependentInfo
|
||||||
dependentInfo?: DependentInfo
|
|
||||||
|
|
||||||
pkg!: PackageDataEntry
|
pkg!: PackageDataEntry
|
||||||
loadingText!: string
|
loadingText = ''
|
||||||
configSpec!: ConfigSpec
|
|
||||||
configForm!: UntypedFormGroup
|
configSpec?: ConfigSpec
|
||||||
|
configForm?: UntypedFormGroup
|
||||||
|
|
||||||
original?: object // only if existing config
|
original?: object // only if existing config
|
||||||
diff?: string[] // only if dependent info
|
diff?: string[] // only if dependent info
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
hasConfig = false
|
|
||||||
hasNewOptions = false
|
hasNewOptions = false
|
||||||
saving = false
|
saving = false
|
||||||
loadingError: string | IonicSafeString = ''
|
loadingError: string | IonicSafeString = ''
|
||||||
@@ -64,9 +63,8 @@ export class AppConfigPage {
|
|||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
try {
|
try {
|
||||||
this.pkg = await getPackage(this.patch, this.pkgId)
|
this.pkg = await getPackage(this.patch, this.pkgId)
|
||||||
this.hasConfig = !!this.pkg.manifest.config
|
|
||||||
|
|
||||||
if (!this.hasConfig) return
|
if (!this.pkg.manifest.config) return
|
||||||
|
|
||||||
let newConfig: object | undefined
|
let newConfig: object | undefined
|
||||||
let patch: Operation[] | undefined
|
let patch: Operation[] | undefined
|
||||||
@@ -118,7 +116,7 @@ export class AppConfigPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dismiss() {
|
async dismiss() {
|
||||||
if (this.configForm.dirty) {
|
if (this.configForm?.dirty) {
|
||||||
this.presentAlertUnsaved()
|
this.presentAlertUnsaved()
|
||||||
} else {
|
} else {
|
||||||
this.modalCtrl.dismiss()
|
this.modalCtrl.dismiss()
|
||||||
@@ -126,9 +124,9 @@ export class AppConfigPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async tryConfigure() {
|
async tryConfigure() {
|
||||||
convertValuesRecursive(this.configSpec, this.configForm)
|
convertValuesRecursive(this.configSpec!, this.configForm!)
|
||||||
|
|
||||||
if (this.configForm.invalid) {
|
if (this.configForm!.invalid) {
|
||||||
document
|
document
|
||||||
.getElementsByClassName('validation-error')[0]
|
.getElementsByClassName('validation-error')[0]
|
||||||
?.scrollIntoView({ behavior: 'smooth' })
|
?.scrollIntoView({ behavior: 'smooth' })
|
||||||
@@ -153,7 +151,7 @@ export class AppConfigPage {
|
|||||||
try {
|
try {
|
||||||
const breakages = await this.embassyApi.drySetPackageConfig({
|
const breakages = await this.embassyApi.drySetPackageConfig({
|
||||||
id: this.pkgId,
|
id: this.pkgId,
|
||||||
config: this.configForm.value,
|
config: this.configForm!.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isEmptyObject(breakages)) {
|
if (isEmptyObject(breakages)) {
|
||||||
@@ -186,7 +184,7 @@ export class AppConfigPage {
|
|||||||
try {
|
try {
|
||||||
await this.embassyApi.setPackageConfig({
|
await this.embassyApi.setPackageConfig({
|
||||||
id: this.pkgId,
|
id: this.pkgId,
|
||||||
config: this.configForm.value,
|
config: this.configForm!.value,
|
||||||
})
|
})
|
||||||
this.modalCtrl.dismiss()
|
this.modalCtrl.dismiss()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -304,11 +302,11 @@ export class AppConfigPage {
|
|||||||
return isNaN(num) ? node : num
|
return isNaN(num) ? node : num
|
||||||
})
|
})
|
||||||
|
|
||||||
if (op.op !== 'remove') this.configForm.get(arrPath)?.markAsDirty()
|
if (op.op !== 'remove') this.configForm!.get(arrPath)?.markAsDirty()
|
||||||
|
|
||||||
if (typeof arrPath[arrPath.length - 1] === 'number') {
|
if (typeof arrPath[arrPath.length - 1] === 'number') {
|
||||||
const prevPath = arrPath.slice(0, arrPath.length - 1)
|
const prevPath = arrPath.slice(0, arrPath.length - 1)
|
||||||
this.configForm.get(prevPath)?.markAsDirty()
|
this.configForm!.get(prevPath)?.markAsDirty()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Component, Input } from '@angular/core'
|
import { Component, Input } from '@angular/core'
|
||||||
import { ActivatedRoute } from '@angular/router'
|
import { ActivatedRoute } from '@angular/router'
|
||||||
import { ModalController, ToastController } from '@ionic/angular'
|
import { ModalController, ToastController } from '@ionic/angular'
|
||||||
import { getPkgId } from '@start9labs/shared'
|
import { getPkgId, copyToClipboard } from '@start9labs/shared'
|
||||||
import { getUiInterfaceKey } from 'src/app/services/config.service'
|
import { getUiInterfaceKey } from 'src/app/services/config.service'
|
||||||
import {
|
import {
|
||||||
InstalledPackageDataEntry,
|
InstalledPackageDataEntry,
|
||||||
InterfaceDef,
|
InterfaceDef,
|
||||||
} from 'src/app/services/patch-db/data-model'
|
} from 'src/app/services/patch-db/data-model'
|
||||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||||
import { copyToClipboard } from 'src/app/util/web.util'
|
|
||||||
import { QRComponent } from 'src/app/components/qr/qr.component'
|
import { QRComponent } from 'src/app/components/qr/qr.component'
|
||||||
import { getPackage } from '../../../util/get-package-data'
|
import { getPackage } from '../../../util/get-package-data'
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'
|
|||||||
import { Routes, RouterModule } from '@angular/router'
|
import { Routes, RouterModule } from '@angular/router'
|
||||||
import { IonicModule } from '@ionic/angular'
|
import { IonicModule } from '@ionic/angular'
|
||||||
import { AppLogsPage } from './app-logs.page'
|
import { AppLogsPage } from './app-logs.page'
|
||||||
import { SharedPipesModule } from '@start9labs/shared'
|
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
|
||||||
import { LogsPageModule } from 'src/app/components/logs/logs.module'
|
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -18,8 +17,7 @@ const routes: Routes = [
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
IonicModule,
|
IonicModule,
|
||||||
RouterModule.forChild(routes),
|
RouterModule.forChild(routes),
|
||||||
SharedPipesModule,
|
LogsComponentModule,
|
||||||
LogsPageModule,
|
|
||||||
],
|
],
|
||||||
declarations: [AppLogsPage],
|
declarations: [AppLogsPage],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
<ion-header>
|
<logs
|
||||||
<ion-toolbar>
|
[fetchLogs]="fetchLogs()"
|
||||||
<ion-buttons slot="start">
|
[followLogs]="followLogs()"
|
||||||
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
|
[defaultBack]="'/services/' + pkgId"
|
||||||
</ion-buttons>
|
title="Service Logs"
|
||||||
<ion-title>Logs</ion-title>
|
class="ion-page"
|
||||||
<ion-button slot="end" fill="clear" size="small" (click)="copy()">
|
></logs>
|
||||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
</ion-toolbar>
|
|
||||||
</ion-header>
|
|
||||||
|
|
||||||
<div style="height: 100%">
|
|
||||||
<logs [fetchLogs]="fetchFetchLogs()"></logs>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Component } from '@angular/core'
|
import { Component } from '@angular/core'
|
||||||
import { ActivatedRoute } from '@angular/router'
|
import { ActivatedRoute } from '@angular/router'
|
||||||
import { getPkgId } from '@start9labs/shared'
|
import { getPkgId } from '@start9labs/shared'
|
||||||
import { ToastController } from '@ionic/angular'
|
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { copyToClipboard, strip } from 'src/app/util/web.util'
|
import { RR } from 'src/app/services/api/api.types'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-logs',
|
selector: 'app-logs',
|
||||||
@@ -16,39 +15,23 @@ export class AppLogsPage {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly route: ActivatedRoute,
|
private readonly route: ActivatedRoute,
|
||||||
private readonly embassyApi: ApiService,
|
private readonly embassyApi: ApiService,
|
||||||
private readonly toastCtrl: ToastController,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
fetchFetchLogs() {
|
followLogs() {
|
||||||
return async (params: {
|
return async (params: RR.FollowServerLogsReq) => {
|
||||||
before_flag?: boolean
|
return this.embassyApi.followPackageLogs({
|
||||||
limit?: number
|
|
||||||
cursor?: string
|
|
||||||
}) => {
|
|
||||||
return this.embassyApi.getPackageLogs({
|
|
||||||
id: this.pkgId,
|
id: this.pkgId,
|
||||||
before_flag: params.before_flag,
|
...params,
|
||||||
cursor: params.cursor,
|
|
||||||
limit: params.limit,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async copy(): Promise<void> {
|
fetchLogs() {
|
||||||
const logs = document
|
return async (params: RR.GetServerLogsReq) => {
|
||||||
.getElementById('template')
|
return this.embassyApi.getPackageLogs({
|
||||||
?.cloneNode(true) as HTMLElement
|
id: this.pkgId,
|
||||||
const formatted = '```' + strip(logs.innerHTML) + '```'
|
...params,
|
||||||
const success = await copyToClipboard(formatted)
|
})
|
||||||
const 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Component, ViewChild } from '@angular/core'
|
import { Component, ViewChild } from '@angular/core'
|
||||||
import { ActivatedRoute } from '@angular/router'
|
import { ActivatedRoute } from '@angular/router'
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { copyToClipboard } from 'src/app/util/web.util'
|
|
||||||
import {
|
import {
|
||||||
AlertController,
|
AlertController,
|
||||||
IonBackButtonDelegate,
|
IonBackButtonDelegate,
|
||||||
@@ -13,7 +12,12 @@ import { PackageProperties } from 'src/app/util/properties.util'
|
|||||||
import { QRComponent } from 'src/app/components/qr/qr.component'
|
import { QRComponent } from 'src/app/components/qr/qr.component'
|
||||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||||
import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
|
import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
|
||||||
import { DestroyService, ErrorToastService, getPkgId } from '@start9labs/shared'
|
import {
|
||||||
|
DestroyService,
|
||||||
|
ErrorToastService,
|
||||||
|
getPkgId,
|
||||||
|
copyToClipboard,
|
||||||
|
} from '@start9labs/shared'
|
||||||
import { getValueByPointer } from 'fast-json-patch'
|
import { getValueByPointer } from 'fast-json-patch'
|
||||||
import { map, takeUntil } from 'rxjs/operators'
|
import { map, takeUntil } from 'rxjs/operators'
|
||||||
|
|
||||||
|
|||||||
@@ -9,31 +9,46 @@ const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'list',
|
path: 'list',
|
||||||
loadChildren: () => import('./app-list/app-list.module').then(m => m.AppListPageModule),
|
loadChildren: () =>
|
||||||
|
import('./app-list/app-list.module').then(m => m.AppListPageModule),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':pkgId',
|
path: ':pkgId',
|
||||||
loadChildren: () => import('./app-show/app-show.module').then(m => m.AppShowPageModule),
|
loadChildren: () =>
|
||||||
|
import('./app-show/app-show.module').then(m => m.AppShowPageModule),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':pkgId/actions',
|
path: ':pkgId/actions',
|
||||||
loadChildren: () => import('./app-actions/app-actions.module').then(m => m.AppActionsPageModule),
|
loadChildren: () =>
|
||||||
|
import('./app-actions/app-actions.module').then(
|
||||||
|
m => m.AppActionsPageModule,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':pkgId/interfaces',
|
path: ':pkgId/interfaces',
|
||||||
loadChildren: () => import('./app-interfaces/app-interfaces.module').then(m => m.AppInterfacesPageModule),
|
loadChildren: () =>
|
||||||
|
import('./app-interfaces/app-interfaces.module').then(
|
||||||
|
m => m.AppInterfacesPageModule,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':pkgId/logs',
|
path: ':pkgId/logs',
|
||||||
loadChildren: () => import('./app-logs/app-logs.module').then(m => m.AppLogsPageModule),
|
loadChildren: () =>
|
||||||
|
import('./app-logs/app-logs.module').then(m => m.AppLogsPageModule),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':pkgId/metrics',
|
path: ':pkgId/metrics',
|
||||||
loadChildren: () => import('./app-metrics/app-metrics.module').then(m => m.AppMetricsPageModule),
|
loadChildren: () =>
|
||||||
|
import('./app-metrics/app-metrics.module').then(
|
||||||
|
m => m.AppMetricsPageModule,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':pkgId/properties',
|
path: ':pkgId/properties',
|
||||||
loadChildren: () => import('./app-properties/app-properties.module').then(m => m.AppPropertiesPageModule),
|
loadChildren: () =>
|
||||||
|
import('./app-properties/app-properties.module').then(
|
||||||
|
m => m.AppPropertiesPageModule,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -41,4 +56,4 @@ const routes: Routes = [
|
|||||||
imports: [RouterModule.forChild(routes)],
|
imports: [RouterModule.forChild(routes)],
|
||||||
exports: [RouterModule],
|
exports: [RouterModule],
|
||||||
})
|
})
|
||||||
export class AppsRoutingModule { }
|
export class AppsRoutingModule {}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'
|
|||||||
import { Routes, RouterModule } from '@angular/router'
|
import { Routes, RouterModule } from '@angular/router'
|
||||||
import { IonicModule } from '@ionic/angular'
|
import { IonicModule } from '@ionic/angular'
|
||||||
import { KernelLogsPage } from './kernel-logs.page'
|
import { KernelLogsPage } from './kernel-logs.page'
|
||||||
import { SharedPipesModule } from '@start9labs/shared'
|
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
|
||||||
import { LogsPageModule } from 'src/app/components/logs/logs.module'
|
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -18,8 +17,7 @@ const routes: Routes = [
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
IonicModule,
|
IonicModule,
|
||||||
RouterModule.forChild(routes),
|
RouterModule.forChild(routes),
|
||||||
SharedPipesModule,
|
LogsComponentModule,
|
||||||
LogsPageModule,
|
|
||||||
],
|
],
|
||||||
declarations: [KernelLogsPage],
|
declarations: [KernelLogsPage],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
<ion-header>
|
<logs
|
||||||
<ion-toolbar>
|
[fetchLogs]="fetchLogs()"
|
||||||
<ion-buttons slot="start">
|
[followLogs]="followLogs()"
|
||||||
<ion-back-button defaultHref="embassy"></ion-back-button>
|
defaultBack="embassy"
|
||||||
</ion-buttons>
|
title="Kernel Logs"
|
||||||
<ion-title>Kernel Logs</ion-title>
|
class="ion-page"
|
||||||
<ion-buttons slot="end">
|
></logs>
|
||||||
<ion-button (click)="copy()">
|
|
||||||
<ion-icon name="copy-outline"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
</ion-buttons>
|
|
||||||
</ion-toolbar>
|
|
||||||
</ion-header>
|
|
||||||
|
|
||||||
<div style="height: 100%">
|
|
||||||
<logs [fetchLogs]="fetchFetchLogs()"></logs>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Component } from '@angular/core'
|
import { Component } from '@angular/core'
|
||||||
import { ToastController } from '@ionic/angular'
|
import { RR } from 'src/app/services/api/api.types'
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { copyToClipboard, strip } from 'src/app/util/web.util'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'kernel-logs',
|
selector: 'kernel-logs',
|
||||||
@@ -9,40 +8,17 @@ import { copyToClipboard, strip } from 'src/app/util/web.util'
|
|||||||
styleUrls: ['./kernel-logs.page.scss'],
|
styleUrls: ['./kernel-logs.page.scss'],
|
||||||
})
|
})
|
||||||
export class KernelLogsPage {
|
export class KernelLogsPage {
|
||||||
constructor(
|
constructor(private readonly embassyApi: ApiService) {}
|
||||||
private readonly embassyApi: ApiService,
|
|
||||||
private readonly toastCtrl: ToastController,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
fetchFetchLogs() {
|
followLogs() {
|
||||||
return async (params: {
|
return async (params: RR.FollowServerLogsReq) => {
|
||||||
before_flag?: boolean
|
return this.embassyApi.followKernelLogs(params)
|
||||||
limit?: number
|
|
||||||
cursor?: string
|
|
||||||
}) => {
|
|
||||||
return this.embassyApi.getKernelLogs({
|
|
||||||
before_flag: params.before_flag,
|
|
||||||
cursor: params.cursor,
|
|
||||||
limit: params.limit,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async copy(): Promise<void> {
|
fetchLogs() {
|
||||||
const logs = document
|
return async (params: RR.GetServerLogsReq) => {
|
||||||
.getElementById('template')
|
return this.embassyApi.getKernelLogs(params)
|
||||||
?.cloneNode(true) as HTMLElement
|
}
|
||||||
const formatted = '```' + strip(logs.innerHTML) + '```'
|
|
||||||
const success = await copyToClipboard(formatted)
|
|
||||||
const 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'
|
|||||||
import { Routes, RouterModule } from '@angular/router'
|
import { Routes, RouterModule } from '@angular/router'
|
||||||
import { IonicModule } from '@ionic/angular'
|
import { IonicModule } from '@ionic/angular'
|
||||||
import { ServerLogsPage } from './server-logs.page'
|
import { ServerLogsPage } from './server-logs.page'
|
||||||
import { SharedPipesModule } from '@start9labs/shared'
|
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
|
||||||
import { LogsPageModule } from 'src/app/components/logs/logs.module'
|
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -18,8 +17,7 @@ const routes: Routes = [
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
IonicModule,
|
IonicModule,
|
||||||
RouterModule.forChild(routes),
|
RouterModule.forChild(routes),
|
||||||
SharedPipesModule,
|
LogsComponentModule,
|
||||||
LogsPageModule,
|
|
||||||
],
|
],
|
||||||
declarations: [ServerLogsPage],
|
declarations: [ServerLogsPage],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
<ion-header>
|
<logs
|
||||||
<ion-toolbar>
|
[fetchLogs]="fetchLogs()"
|
||||||
<ion-buttons slot="start">
|
[followLogs]="followLogs()"
|
||||||
<ion-back-button defaultHref="embassy"></ion-back-button>
|
defaultBack="embassy"
|
||||||
</ion-buttons>
|
title="OS Logs"
|
||||||
<ion-title>OS Logs</ion-title>
|
class="ion-page"
|
||||||
<ion-buttons slot="end">
|
></logs>
|
||||||
<ion-button (click)="copy()">
|
|
||||||
<ion-icon name="copy-outline"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
</ion-buttons>
|
|
||||||
</ion-toolbar>
|
|
||||||
</ion-header>
|
|
||||||
|
|
||||||
<div style="height: 100%">
|
|
||||||
<logs [fetchLogs]="fetchFetchLogs()"></logs>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Component } from '@angular/core'
|
import { Component } from '@angular/core'
|
||||||
import { ToastController } from '@ionic/angular'
|
import { RR } from 'src/app/services/api/api.types'
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { copyToClipboard, strip } from 'src/app/util/web.util'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'server-logs',
|
selector: 'server-logs',
|
||||||
@@ -9,40 +8,17 @@ import { copyToClipboard, strip } from 'src/app/util/web.util'
|
|||||||
styleUrls: ['./server-logs.page.scss'],
|
styleUrls: ['./server-logs.page.scss'],
|
||||||
})
|
})
|
||||||
export class ServerLogsPage {
|
export class ServerLogsPage {
|
||||||
constructor(
|
constructor(private readonly embassyApi: ApiService) {}
|
||||||
private readonly embassyApi: ApiService,
|
|
||||||
private readonly toastCtrl: ToastController,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
fetchFetchLogs() {
|
followLogs() {
|
||||||
return async (params: {
|
return async (params: RR.FollowServerLogsReq) => {
|
||||||
before_flag?: boolean
|
return this.embassyApi.followServerLogs(params)
|
||||||
limit?: number
|
|
||||||
cursor?: string
|
|
||||||
}) => {
|
|
||||||
return this.embassyApi.getServerLogs({
|
|
||||||
before_flag: params.before_flag,
|
|
||||||
cursor: params.cursor,
|
|
||||||
limit: params.limit,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async copy(): Promise<void> {
|
fetchLogs() {
|
||||||
const logs = document
|
return async (params: RR.GetServerLogsReq) => {
|
||||||
.getElementById('template')
|
return this.embassyApi.getServerLogs(params)
|
||||||
?.cloneNode(true) as HTMLElement
|
}
|
||||||
const formatted = '```' + strip(logs.innerHTML) + '```'
|
|
||||||
const success = await copyToClipboard(formatted)
|
|
||||||
const 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component } from '@angular/core'
|
import { Component } from '@angular/core'
|
||||||
import { ToastController } from '@ionic/angular'
|
import { ToastController } from '@ionic/angular'
|
||||||
import { copyToClipboard } from 'src/app/util/web.util'
|
|
||||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||||
import { ConfigService } from 'src/app/services/config.service'
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
|
import { copyToClipboard } from '@start9labs/shared'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'server-specs',
|
selector: 'server-specs',
|
||||||
|
|||||||
@@ -7,16 +7,11 @@ import {
|
|||||||
PackageState,
|
PackageState,
|
||||||
ServerStatusInfo,
|
ServerStatusInfo,
|
||||||
} from 'src/app/services/patch-db/data-model'
|
} from 'src/app/services/patch-db/data-model'
|
||||||
import {
|
import { Metric, RR, NotificationLevel, ServerNotifications } from './api.types'
|
||||||
Log,
|
|
||||||
Metric,
|
|
||||||
RR,
|
|
||||||
NotificationLevel,
|
|
||||||
ServerNotifications,
|
|
||||||
} from './api.types'
|
|
||||||
|
|
||||||
import { BTC_ICON, LND_ICON, PROXY_ICON } from './api-icons'
|
import { BTC_ICON, LND_ICON, PROXY_ICON } from './api-icons'
|
||||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
import { MarketplacePkg } from '@start9labs/marketplace'
|
||||||
|
import { Log } from '@start9labs/shared'
|
||||||
|
|
||||||
export module Mock {
|
export module Mock {
|
||||||
export const ServerUpdated: ServerStatusInfo = {
|
export const ServerUpdated: ServerStatusInfo = {
|
||||||
@@ -955,7 +950,7 @@ export module Mock {
|
|||||||
{
|
{
|
||||||
timestamp: '2019-12-26T14:21:30.872Z',
|
timestamp: '2019-12-26T14:21:30.872Z',
|
||||||
message:
|
message:
|
||||||
'\u001b[34mPOST \u001b[0;32;49m200\u001b[0m photoview.embassy/api/graphql \u001b[0;36;49m1.169406ms\u001b[0m unauthenticated<p>TEST PARAGRAPH</p>',
|
'\u001b[34mPOST \u001b[0;32;49m200\u001b[0m photoview.embassy/api/graphql \u001b[0;36;49m1.169406ms\u001b',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timestamp: '2019-12-26T14:22:30.872Z',
|
timestamp: '2019-12-26T14:22:30.872Z',
|
||||||
@@ -1439,7 +1434,7 @@ export module Mock {
|
|||||||
'bitcoin-node': {
|
'bitcoin-node': {
|
||||||
name: 'Bitcoin Node Settings',
|
name: 'Bitcoin Node Settings',
|
||||||
type: 'union',
|
type: 'union',
|
||||||
description: 'The node settings',
|
description: 'Options<ul><li>Item 1</li><li>Item 2</li></ul>',
|
||||||
default: 'internal',
|
default: 'internal',
|
||||||
warning: 'Careful changing this',
|
warning: 'Careful changing this',
|
||||||
tag: {
|
tag: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DependencyError,
|
DependencyError,
|
||||||
Manifest,
|
Manifest,
|
||||||
} from 'src/app/services/patch-db/data-model'
|
} from 'src/app/services/patch-db/data-model'
|
||||||
|
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||||
|
|
||||||
export module RR {
|
export module RR {
|
||||||
// DB
|
// DB
|
||||||
@@ -28,13 +29,15 @@ export module RR {
|
|||||||
|
|
||||||
// server
|
// server
|
||||||
|
|
||||||
export type GetServerLogsReq = {
|
export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs
|
||||||
cursor?: string
|
|
||||||
before_flag?: boolean
|
|
||||||
limit?: number
|
|
||||||
}
|
|
||||||
export type GetServerLogsRes = LogsRes
|
export type GetServerLogsRes = LogsRes
|
||||||
|
|
||||||
|
export type FollowServerLogsReq = { limit?: number } // server.logs.follow & server.kernel-logs.follow
|
||||||
|
export type FollowServerLogsRes = {
|
||||||
|
'start-cursor': string
|
||||||
|
guid: string
|
||||||
|
}
|
||||||
|
|
||||||
export type GetServerMetricsReq = {} // server.metrics
|
export type GetServerMetricsReq = {} // server.metrics
|
||||||
export type GetServerMetricsRes = Metrics
|
export type GetServerMetricsRes = Metrics
|
||||||
|
|
||||||
@@ -160,20 +163,12 @@ export module RR {
|
|||||||
export type GetPackagePropertiesRes<T extends number> =
|
export type GetPackagePropertiesRes<T extends number> =
|
||||||
PackagePropertiesVersioned<T>
|
PackagePropertiesVersioned<T>
|
||||||
|
|
||||||
export type LogsRes = {
|
export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs
|
||||||
entries: Log[]
|
|
||||||
'start-cursor'?: string
|
|
||||||
'end-cursor'?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GetPackageLogsReq = {
|
|
||||||
id: string
|
|
||||||
cursor?: string
|
|
||||||
before_flag?: boolean
|
|
||||||
limit?: number
|
|
||||||
} // package.logs
|
|
||||||
export type GetPackageLogsRes = LogsRes
|
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 GetPackageMetricsReq = { id: string } // package.metrics
|
||||||
export type GetPackageMetricsRes = Metric
|
export type GetPackageMetricsRes = Metric
|
||||||
|
|
||||||
@@ -238,7 +233,7 @@ export module RR {
|
|||||||
spec: ConfigSpec
|
spec: ConfigSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SideloadPackageReq {
|
export type SideloadPackageReq = {
|
||||||
manifest: Manifest
|
manifest: Manifest
|
||||||
icon: string // base64
|
icon: string // base64
|
||||||
}
|
}
|
||||||
@@ -288,11 +283,6 @@ export interface TaggedDependencyError {
|
|||||||
error: DependencyError
|
error: DependencyError
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Log {
|
|
||||||
timestamp: string
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActionResponse {
|
export interface ActionResponse {
|
||||||
message: string
|
message: string
|
||||||
value: string | null
|
value: string | null
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
} from 'patch-db-client'
|
} from 'patch-db-client'
|
||||||
import { RR } from './api.types'
|
import { RR } from './api.types'
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
import { RequestError } from '../http.service'
|
import { Log, RequestError } from '@start9labs/shared'
|
||||||
import { map } from 'rxjs/operators'
|
import { map } from 'rxjs/operators'
|
||||||
|
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||||
|
|
||||||
export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
|
export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
|
||||||
protected readonly sync$ = new Subject<Update<DataModel>>()
|
protected readonly sync$ = new Subject<Update<DataModel>>()
|
||||||
@@ -24,6 +25,14 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
|
|||||||
.pipe(map(result => ({ result, jsonrpc: '2.0' })))
|
.pipe(map(result => ({ result, jsonrpc: '2.0' })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// websocket
|
||||||
|
|
||||||
|
abstract openLogsWebsocket$(
|
||||||
|
config: WebSocketSubjectConfig<Log>,
|
||||||
|
): Observable<Log>
|
||||||
|
|
||||||
|
// http
|
||||||
|
|
||||||
// for getting static files: ex icons, instructions, licenses
|
// for getting static files: ex icons, instructions, licenses
|
||||||
abstract getStatic(url: string): Promise<string>
|
abstract getStatic(url: string): Promise<string>
|
||||||
|
|
||||||
@@ -62,6 +71,14 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
|
|||||||
params: RR.GetServerLogsReq,
|
params: RR.GetServerLogsReq,
|
||||||
): Promise<RR.GetServerLogsRes>
|
): Promise<RR.GetServerLogsRes>
|
||||||
|
|
||||||
|
abstract followServerLogs(
|
||||||
|
params: RR.FollowServerLogsReq,
|
||||||
|
): Promise<RR.FollowServerLogsRes>
|
||||||
|
|
||||||
|
abstract followKernelLogs(
|
||||||
|
params: RR.FollowServerLogsReq,
|
||||||
|
): Promise<RR.FollowServerLogsRes>
|
||||||
|
|
||||||
abstract getServerMetrics(
|
abstract getServerMetrics(
|
||||||
params: RR.GetServerMetricsReq,
|
params: RR.GetServerMetricsReq,
|
||||||
): Promise<RR.GetServerMetricsRes>
|
): Promise<RR.GetServerMetricsRes>
|
||||||
@@ -193,6 +210,10 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
|
|||||||
params: RR.GetPackageLogsReq,
|
params: RR.GetPackageLogsReq,
|
||||||
): Promise<RR.GetPackageLogsRes>
|
): Promise<RR.GetPackageLogsRes>
|
||||||
|
|
||||||
|
abstract followPackageLogs(
|
||||||
|
params: RR.FollowPackageLogsReq,
|
||||||
|
): Promise<RR.FollowPackageLogsRes>
|
||||||
|
|
||||||
protected abstract installPackageRaw(
|
protected abstract installPackageRaw(
|
||||||
params: RR.InstallPackageReq,
|
params: RR.InstallPackageReq,
|
||||||
): Promise<RR.InstallPackageRes>
|
): Promise<RR.InstallPackageRes>
|
||||||
@@ -280,7 +301,7 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
return f(a)
|
return f(a)
|
||||||
.catch((e: RequestError) => {
|
.catch((e: UIRequestError) => {
|
||||||
if (e.revision) this.sync$.next(e.revision)
|
if (e.revision) this.sync$.next(e.revision)
|
||||||
throw e
|
throw e
|
||||||
})
|
})
|
||||||
@@ -291,3 +312,5 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UIRequestError = RequestError & { revision: Revision }
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { HttpService, Method } from '../http.service'
|
import { HttpService, Log, LogsRes, Method } from '@start9labs/shared'
|
||||||
import { ApiService } from './embassy-api.service'
|
import { ApiService } from './embassy-api.service'
|
||||||
import { RR } from './api.types'
|
import { RR } from './api.types'
|
||||||
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
|
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
|
||||||
import { ConfigService } from '../config.service'
|
import { ConfigService } from '../config.service'
|
||||||
|
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||||
|
import { Observable } from 'rxjs'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LiveApiService extends ApiService {
|
export class LiveApiService extends ApiService {
|
||||||
@@ -12,7 +14,11 @@ export class LiveApiService extends ApiService {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
; (window as any).rpcClient = this
|
; (window as any).rpcClient = this
|
||||||
|
}
|
||||||
|
|
||||||
|
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||||
|
return webSocket(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatic(url: string): Promise<string> {
|
async getStatic(url: string): Promise<string> {
|
||||||
@@ -39,7 +45,7 @@ export class LiveApiService extends ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDump(): Promise<RR.GetDumpRes> {
|
async getDump(): Promise<RR.GetDumpRes> {
|
||||||
return this.http.rpcRequest({ method: 'db.dump' })
|
return this.http.rpcRequest({ method: 'db.dump', params: {} })
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDbValueRaw(params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
|
async setDbValueRaw(params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
|
||||||
@@ -78,6 +84,18 @@ export class LiveApiService extends ApiService {
|
|||||||
return this.http.rpcRequest({ method: 'server.kernel-logs', params })
|
return this.http.rpcRequest({ method: 'server.kernel-logs', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async followServerLogs(
|
||||||
|
params: RR.FollowServerLogsReq,
|
||||||
|
): Promise<RR.FollowServerLogsRes> {
|
||||||
|
return this.http.rpcRequest({ method: 'server.logs.follow', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
async followKernelLogs(
|
||||||
|
params: RR.FollowServerLogsReq,
|
||||||
|
): Promise<RR.FollowServerLogsRes> {
|
||||||
|
return this.http.rpcRequest({ method: 'server.kernel-logs.follow', params })
|
||||||
|
}
|
||||||
|
|
||||||
async getServerMetrics(
|
async getServerMetrics(
|
||||||
params: RR.GetServerMetricsReq,
|
params: RR.GetServerMetricsReq,
|
||||||
): Promise<RR.GetServerMetricsRes> {
|
): Promise<RR.GetServerMetricsRes> {
|
||||||
@@ -252,6 +270,12 @@ export class LiveApiService extends ApiService {
|
|||||||
return this.http.rpcRequest({ method: 'package.logs', params })
|
return this.http.rpcRequest({ method: 'package.logs', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async followPackageLogs(
|
||||||
|
params: RR.FollowServerLogsReq,
|
||||||
|
): Promise<RR.FollowServerLogsRes> {
|
||||||
|
return this.http.rpcRequest({ method: 'package.logs.follow', params })
|
||||||
|
}
|
||||||
|
|
||||||
async getPkgMetrics(
|
async getPkgMetrics(
|
||||||
params: RR.GetPackageMetricsReq,
|
params: RR.GetPackageMetricsReq,
|
||||||
): Promise<RR.GetPackageMetricsRes> {
|
): Promise<RR.GetPackageMetricsRes> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { pauseFor } from '@start9labs/shared'
|
import { pauseFor, Log, LogsRes } from '@start9labs/shared'
|
||||||
import { ApiService } from './embassy-api.service'
|
import { ApiService } from './embassy-api.service'
|
||||||
import { PatchOp, Update, Operation, RemoveOperation } from 'patch-db-client'
|
import { PatchOp, Update, Operation, RemoveOperation } from 'patch-db-client'
|
||||||
import {
|
import {
|
||||||
@@ -11,13 +11,14 @@ import {
|
|||||||
PackageState,
|
PackageState,
|
||||||
ServerStatus,
|
ServerStatus,
|
||||||
} from 'src/app/services/patch-db/data-model'
|
} from 'src/app/services/patch-db/data-model'
|
||||||
import { CifsBackupTarget, Log, RR, WithRevision } from './api.types'
|
import { CifsBackupTarget, RR, WithRevision } from './api.types'
|
||||||
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
|
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
|
||||||
import { Mock } from './api.fixures'
|
import { Mock } from './api.fixures'
|
||||||
import markdown from 'raw-loader!../../../../../../assets/markdown/md-sample.md'
|
import markdown from 'raw-loader!../../../../../../assets/markdown/md-sample.md'
|
||||||
import { BehaviorSubject } from 'rxjs'
|
import { BehaviorSubject, interval, map, Observable, tap } from 'rxjs'
|
||||||
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
|
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
|
||||||
import { mockPatchData } from './mock-patch'
|
import { mockPatchData } from './mock-patch'
|
||||||
|
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||||
|
|
||||||
const PROGRESS: InstallProgress = {
|
const PROGRESS: InstallProgress = {
|
||||||
size: 120,
|
size: 120,
|
||||||
@@ -43,6 +44,16 @@ export class MockApiService extends ApiService {
|
|||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||||
|
return interval(100).pipe(
|
||||||
|
map((_, index) => {
|
||||||
|
// mock fire open observer
|
||||||
|
if (index === 0) config.openObserver?.next(new Event(''))
|
||||||
|
return Mock.ServerLogs[0]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async getStatic(url: string): Promise<string> {
|
async getStatic(url: string): Promise<string> {
|
||||||
await pauseFor(2000)
|
await pauseFor(2000)
|
||||||
return markdown
|
return markdown
|
||||||
@@ -113,17 +124,8 @@ export class MockApiService extends ApiService {
|
|||||||
params: RR.GetServerLogsReq,
|
params: RR.GetServerLogsReq,
|
||||||
): Promise<RR.GetServerLogsRes> {
|
): Promise<RR.GetServerLogsRes> {
|
||||||
await pauseFor(2000)
|
await pauseFor(2000)
|
||||||
let entries: Log[]
|
const entries = this.randomLogs(params.limit)
|
||||||
if (Math.random() < 0.2) {
|
|
||||||
entries = Mock.ServerLogs
|
|
||||||
} else {
|
|
||||||
const arrLength = params.limit
|
|
||||||
? Math.ceil(params.limit / Mock.ServerLogs.length)
|
|
||||||
: 10
|
|
||||||
entries = new Array(arrLength)
|
|
||||||
.fill(Mock.ServerLogs)
|
|
||||||
.reduce((acc, val) => acc.concat(val), [])
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
entries,
|
entries,
|
||||||
'start-cursor': 'startCursor',
|
'start-cursor': 'startCursor',
|
||||||
@@ -135,17 +137,8 @@ export class MockApiService extends ApiService {
|
|||||||
params: RR.GetServerLogsReq,
|
params: RR.GetServerLogsReq,
|
||||||
): Promise<RR.GetServerLogsRes> {
|
): Promise<RR.GetServerLogsRes> {
|
||||||
await pauseFor(2000)
|
await pauseFor(2000)
|
||||||
let entries: Log[]
|
const entries = this.randomLogs(params.limit)
|
||||||
if (Math.random() < 0.2) {
|
|
||||||
entries = Mock.ServerLogs
|
|
||||||
} else {
|
|
||||||
const arrLength = params.limit
|
|
||||||
? Math.ceil(params.limit / Mock.ServerLogs.length)
|
|
||||||
: 10
|
|
||||||
entries = new Array(arrLength)
|
|
||||||
.fill(Mock.ServerLogs)
|
|
||||||
.reduce((acc, val) => acc.concat(val), [])
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
entries,
|
entries,
|
||||||
'start-cursor': 'startCursor',
|
'start-cursor': 'startCursor',
|
||||||
@@ -153,6 +146,35 @@ export class MockApiService extends ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async followServerLogs(
|
||||||
|
params: RR.FollowServerLogsReq,
|
||||||
|
): Promise<RR.FollowServerLogsRes> {
|
||||||
|
await pauseFor(2000)
|
||||||
|
return {
|
||||||
|
'start-cursor': 'start-cursor',
|
||||||
|
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async followKernelLogs(
|
||||||
|
params: RR.FollowServerLogsReq,
|
||||||
|
): Promise<RR.FollowServerLogsRes> {
|
||||||
|
await pauseFor(2000)
|
||||||
|
return {
|
||||||
|
'start-cursor': 'start-cursor',
|
||||||
|
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
randomLogs(limit = 1): Log[] {
|
||||||
|
const arrLength = Math.ceil(limit / Mock.ServerLogs.length)
|
||||||
|
const logs = new Array(arrLength)
|
||||||
|
.fill(Mock.ServerLogs)
|
||||||
|
.reduce((acc, val) => acc.concat(val), [])
|
||||||
|
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
|
||||||
async getServerMetrics(
|
async getServerMetrics(
|
||||||
params: RR.GetServerMetricsReq,
|
params: RR.GetServerMetricsReq,
|
||||||
): Promise<RR.GetServerMetricsRes> {
|
): Promise<RR.GetServerMetricsRes> {
|
||||||
@@ -485,6 +507,16 @@ export class MockApiService extends ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async followPackageLogs(
|
||||||
|
params: RR.FollowPackageLogsReq,
|
||||||
|
): Promise<RR.FollowPackageLogsRes> {
|
||||||
|
await pauseFor(2000)
|
||||||
|
return {
|
||||||
|
'start-cursor': 'start-cursor',
|
||||||
|
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async installPackageRaw(
|
async installPackageRaw(
|
||||||
params: RR.InstallPackageReq,
|
params: RR.InstallPackageReq,
|
||||||
): Promise<RR.InstallPackageRes> {
|
): Promise<RR.InstallPackageRes> {
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core'
|
|
||||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
|
|
||||||
import {
|
|
||||||
Observable,
|
|
||||||
from,
|
|
||||||
interval,
|
|
||||||
race,
|
|
||||||
firstValueFrom,
|
|
||||||
lastValueFrom,
|
|
||||||
} from 'rxjs'
|
|
||||||
import { map, take } from 'rxjs/operators'
|
|
||||||
import { ConfigService } from './config.service'
|
|
||||||
import { Revision } from 'patch-db-client'
|
|
||||||
import { AuthService } from './auth.service'
|
|
||||||
import { HttpError, RpcError } from '@start9labs/shared'
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class HttpService {
|
|
||||||
fullUrl: string
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly http: HttpClient,
|
|
||||||
private readonly config: ConfigService,
|
|
||||||
private readonly auth: AuthService,
|
|
||||||
) {
|
|
||||||
const port = window.location.port
|
|
||||||
this.fullUrl = `${window.location.protocol}//${window.location.hostname}:${port}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore TODO: fix typing
|
|
||||||
async rpcRequest<T>(rpcOpts: RPCOptions): Promise<T> {
|
|
||||||
const { url, version } = this.config.api
|
|
||||||
rpcOpts.params = rpcOpts.params || {}
|
|
||||||
const httpOpts: HttpOptions = {
|
|
||||||
method: Method.POST,
|
|
||||||
body: rpcOpts,
|
|
||||||
url: `/${url}/${version}`,
|
|
||||||
}
|
|
||||||
if (rpcOpts.timeout) httpOpts.timeout = rpcOpts.timeout
|
|
||||||
|
|
||||||
const res = await this.httpRequest<RPCResponse<T>>(httpOpts)
|
|
||||||
if (isRpcError(res)) {
|
|
||||||
// code 34 is authorization error ie. invalid session
|
|
||||||
if (res.error.code === 34) this.auth.setUnverified()
|
|
||||||
throw new RpcError(res.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.result
|
|
||||||
}
|
|
||||||
|
|
||||||
async httpRequest<T>(httpOpts: HttpOptions): Promise<T> {
|
|
||||||
if (httpOpts.withCredentials !== false) {
|
|
||||||
httpOpts.withCredentials = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlIsRelative = httpOpts.url.startsWith('/')
|
|
||||||
const url = urlIsRelative ? this.fullUrl + httpOpts.url : httpOpts.url
|
|
||||||
const { params } = httpOpts
|
|
||||||
|
|
||||||
if (hasParams(params)) {
|
|
||||||
Object.keys(params).forEach(key => {
|
|
||||||
if (params[key] === undefined) {
|
|
||||||
delete params[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
responseType: httpOpts.responseType || 'json',
|
|
||||||
body: httpOpts.body,
|
|
||||||
observe: 'response',
|
|
||||||
withCredentials: httpOpts.withCredentials,
|
|
||||||
headers: httpOpts.headers,
|
|
||||||
params: httpOpts.params,
|
|
||||||
timeout: httpOpts.timeout,
|
|
||||||
} as any
|
|
||||||
|
|
||||||
let req: Observable<{ body: T }>
|
|
||||||
switch (httpOpts.method) {
|
|
||||||
case Method.GET:
|
|
||||||
req = this.http.get(url, options) as any
|
|
||||||
break
|
|
||||||
case Method.POST:
|
|
||||||
req = this.http.post(url, httpOpts.body, options) as any
|
|
||||||
break
|
|
||||||
case Method.PUT:
|
|
||||||
req = this.http.put(url, httpOpts.body, options) as any
|
|
||||||
break
|
|
||||||
case Method.PATCH:
|
|
||||||
req = this.http.patch(url, httpOpts.body, options) as any
|
|
||||||
break
|
|
||||||
case Method.DELETE:
|
|
||||||
req = this.http.delete(url, options) as any
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return firstValueFrom(
|
|
||||||
httpOpts.timeout ? withTimeout(req, httpOpts.timeout) : req,
|
|
||||||
)
|
|
||||||
.then(res => res.body)
|
|
||||||
.catch(e => {
|
|
||||||
throw new HttpError(e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRpcError<Error, Result>(
|
|
||||||
arg: { error: Error } | { result: Result },
|
|
||||||
): arg is { error: Error } {
|
|
||||||
return (arg as any).error !== undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestError {
|
|
||||||
code: number
|
|
||||||
message: string
|
|
||||||
details: string
|
|
||||||
revision: Revision | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Method {
|
|
||||||
GET = 'GET',
|
|
||||||
POST = 'POST',
|
|
||||||
PUT = 'PUT',
|
|
||||||
PATCH = 'PATCH',
|
|
||||||
DELETE = 'DELETE',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCOptions {
|
|
||||||
method: string
|
|
||||||
params?: object
|
|
||||||
timeout?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RPCBase {
|
|
||||||
jsonrpc: '2.0'
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCRequest<T> extends RPCBase {
|
|
||||||
method: string
|
|
||||||
params?: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCSuccess<T> extends RPCBase {
|
|
||||||
result: T
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RPCError extends RPCBase {
|
|
||||||
error: {
|
|
||||||
code: number
|
|
||||||
message: string
|
|
||||||
data?:
|
|
||||||
| {
|
|
||||||
details: string
|
|
||||||
revision: Revision | null
|
|
||||||
debug: string | null
|
|
||||||
}
|
|
||||||
| string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RPCResponse<T> = RPCSuccess<T> | RPCError
|
|
||||||
|
|
||||||
export interface HttpOptions {
|
|
||||||
method: Method
|
|
||||||
url: string
|
|
||||||
headers?:
|
|
||||||
| HttpHeaders
|
|
||||||
| {
|
|
||||||
[header: string]: string | string[]
|
|
||||||
}
|
|
||||||
params?:
|
|
||||||
| HttpParams
|
|
||||||
| {
|
|
||||||
[param: string]: string | string[]
|
|
||||||
}
|
|
||||||
responseType?: 'json' | 'text'
|
|
||||||
withCredentials?: boolean
|
|
||||||
body?: any
|
|
||||||
timeout?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasParams(
|
|
||||||
params?: HttpOptions['params'],
|
|
||||||
): params is Record<string, string | string[]> {
|
|
||||||
return !!params
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeout<U>(req: Observable<U>, timeout: number): Observable<U> {
|
|
||||||
return race(
|
|
||||||
from(lastValueFrom(req)), // this guarantees it only emits on completion, intermediary emissions are suppressed.
|
|
||||||
interval(timeout).pipe(
|
|
||||||
take(1),
|
|
||||||
map(() => {
|
|
||||||
throw new Error('timeout')
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,3 @@
|
|||||||
export async function copyToClipboard(str: string): Promise<boolean> {
|
|
||||||
if (window.isSecureContext) {
|
|
||||||
return navigator.clipboard
|
|
||||||
.writeText(str)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const el = document.createElement('textarea')
|
|
||||||
el.value = str
|
|
||||||
el.setAttribute('readonly', '')
|
|
||||||
el.style.position = 'absolute'
|
|
||||||
el.style.left = '-9999px'
|
|
||||||
document.body.appendChild(el)
|
|
||||||
el.select()
|
|
||||||
const copy = document.execCommand('copy')
|
|
||||||
document.body.removeChild(el)
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
|
|
||||||
export function strip(html: string) {
|
export function strip(html: string) {
|
||||||
let doc = new DOMParser().parseFromString(html, 'text/html')
|
let doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
return doc.body.textContent || ''
|
return doc.body.textContent || ''
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"/rpc/v1": {
|
"/rpc/v1": {
|
||||||
"target": "http://<CHANGE_ME>/rpc/v1"
|
"target": "http://<CHANGE_ME>/rpc/v1"
|
||||||
},
|
},
|
||||||
"/ws/db": {
|
"/ws/*": {
|
||||||
"target": "http://<CHANGE_ME>",
|
"target": "http://<CHANGE_ME>",
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"ws": true
|
"ws": true
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use color_eyre::eyre::{eyre, Context, Error};
|
use color_eyre::eyre::{eyre, Context, Error};
|
||||||
use futures::future::BoxFuture;
|
use futures::future::BoxFuture;
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
use tokio::task::{JoinError, JoinHandle};
|
use tokio::task::{JoinError, JoinHandle};
|
||||||
|
|
||||||
mod script_dir;
|
mod script_dir;
|
||||||
@@ -150,3 +152,59 @@ impl Drop for AtomicFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct TimedResource<T: 'static + Send> {
|
||||||
|
handle: NonDetachingJoinHandle<Option<T>>,
|
||||||
|
ready: oneshot::Sender<()>,
|
||||||
|
}
|
||||||
|
impl<T: 'static + Send> TimedResource<T> {
|
||||||
|
pub fn new(resource: T, timer: Duration) -> Self {
|
||||||
|
let (send, recv) = oneshot::channel();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(timer) => {
|
||||||
|
drop(resource);
|
||||||
|
None
|
||||||
|
},
|
||||||
|
_ = recv => Some(resource),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
handle: handle.into(),
|
||||||
|
ready: send,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_destructor<
|
||||||
|
Fn: FnOnce(T) -> Fut + Send + 'static,
|
||||||
|
Fut: Future<Output = ()> + Send,
|
||||||
|
>(
|
||||||
|
resource: T,
|
||||||
|
timer: Duration,
|
||||||
|
destructor: Fn,
|
||||||
|
) -> Self {
|
||||||
|
let (send, recv) = oneshot::channel();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(timer) => {
|
||||||
|
destructor(resource).await;
|
||||||
|
None
|
||||||
|
},
|
||||||
|
_ = recv => Some(resource),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
handle: handle.into(),
|
||||||
|
ready: send,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(self) -> Option<T> {
|
||||||
|
let _ = self.ready.send(());
|
||||||
|
self.handle.await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_timed_out(&self) -> bool {
|
||||||
|
self.ready.is_closed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user