[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:
Chris Guida
2022-08-03 13:06:25 -05:00
committed by GitHub
parent c44eb3a2c3
commit 2f8d825970
70 changed files with 2202 additions and 1795 deletions

568
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -107,7 +107,7 @@ regex = "1.6.0"
reqwest = { version = "0.11.11", features = ["stream", "json", "socks"] }
reqwest_cookie_store = "0.3.0"
rpassword = "6.0.1"
rpc-toolkit = "0.2.0"
rpc-toolkit = "0.2.1"
rust-argon2 = "1.0.0"
scopeguard = "1.1" # because avahi-sys fucks your shit up
serde = { version = "1.0.139", features = ["derive", "rc"] }
@@ -131,7 +131,7 @@ thiserror = "1.0.31"
tokio = { version = "1.19.2", features = ["full"] }
tokio-stream = { version = "0.1.9", features = ["io-util", "sync"] }
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"] }
torut = "0.2.1"
tracing = "0.1.35"

View File

@@ -24,10 +24,14 @@ pub fn auth() -> Result<(), Error> {
Ok(())
}
pub fn parse_metadata(_: &str, _: &ArgMatches) -> Result<Value, Error> {
Ok(serde_json::json!({
pub fn cli_metadata() -> Value {
serde_json::json!({
"platforms": ["cli"],
}))
})
}
pub fn parse_metadata(_: &str, _: &ArgMatches) -> Result<Value, Error> {
Ok(cli_metadata())
}
#[test]
@@ -106,7 +110,7 @@ pub async fn login(
#[arg] password: Option<String>,
#[arg(
parse(parse_metadata),
default = "",
default = "cli_metadata",
help = "RPC Only: This value cannot be overidden from the cli"
)]
metadata: Value,

View File

@@ -151,6 +151,33 @@ async fn inner_main(cfg_path: Option<&str>) -> Result<Option<Shutdown>, Error> {
"/ws/db" => {
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/") => {
match RequestGuid::from(
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())
}
Some(guid) => {
match ctx
.rpc_stream_continuations
.lock()
.await
.remove(&guid)
match ctx.get_rest_continuation_handler(&guid).await
{
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty()),
Some(cont) => match (cont.handler)(req).await {
Some(cont) => match cont(req).await {
Ok(r) => Ok(r),
Err(e) => Response::builder()
.status(

View File

@@ -21,7 +21,7 @@ use tokio::process::Command;
use tokio::sync::{broadcast, oneshot, Mutex, RwLock};
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::hostname::{derive_hostname, derive_id, get_product_key};
use crate::install::cleanup::{cleanup_failed, uninstall, CleanupFailedReceipts};
@@ -387,6 +387,58 @@ impl RpcContext {
}
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 {
fn host(&self) -> Host<&str> {

View File

@@ -1,20 +1,27 @@
use std::time::Instant;
use std::sync::Arc;
use std::time::Duration;
use futures::future::BoxFuture;
use http::{Request, Response};
use hyper::Body;
use futures::FutureExt;
use helpers::TimedResource;
use hyper::upgrade::Upgraded;
use hyper::{Body, Error as HyperError, Request, Response};
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)]
pub struct RequestGuid<T: AsRef<str> = String>(T);
pub struct RequestGuid<T: AsRef<str> = String>(Arc<T>);
impl RequestGuid {
pub fn new() -> Self {
let mut buf = [0; 40];
rand::thread_rng().fill_bytes(&mut buf);
RequestGuid(base32::encode(
RequestGuid(Arc::new(base32::encode(
base32::Alphabet::RFC4648 { padding: false },
&buf,
))
)))
}
pub fn from(r: &str) -> Option<RequestGuid> {
@@ -26,7 +33,7 @@ impl RequestGuid {
return None;
}
}
Some(RequestGuid(r.to_owned()))
Some(RequestGuid(Arc::new(r.to_owned())))
}
}
#[test]
@@ -39,15 +46,71 @@ fn parse_guid() {
impl<T: AsRef<str>> std::fmt::Display for RequestGuid<T> {
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 created_at: Instant,
pub handler: Box<
dyn FnOnce(Request<Body>) -> BoxFuture<'static, Result<Response<Body>, crate::Error>>
+ Send
+ Sync,
>,
pub type RestHandler = Box<
dyn FnOnce(Request<Body>) -> BoxFuture<'static, Result<Response<Body>, crate::Error>> + Send,
>;
pub type WebSocketHandler = Box<
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
}
}
}
}
}

View File

@@ -6,7 +6,7 @@ use rpc_toolkit::yajrc::RpcError;
use crate::context::DiagnosticContext;
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::util::display_none;
use crate::Error;
@@ -23,19 +23,13 @@ pub fn error(#[context] ctx: DiagnosticContext) -> Result<Arc<RpcError>, Error>
Ok(ctx.error.clone())
}
#[command(display(display_logs))]
#[command(rpc_only)]
pub async fn logs(
#[arg] limit: Option<usize>,
#[arg] cursor: Option<String>,
#[arg] before_flag: Option<bool>,
#[arg] before: bool,
) -> Result<LogResponse, Error> {
Ok(fetch_logs(
LogSource::Service(SYSTEMD_UNIT),
limit,
cursor,
before_flag.unwrap_or(false),
)
.await?)
Ok(fetch_logs(LogSource::Service(SYSTEMD_UNIT), limit, cursor, before).await?)
}
#[command(display(display_none))]

View File

@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Duration;
use color_eyre::eyre::eyre;
use emver::VersionRange;
@@ -16,8 +16,8 @@ use http::{Request, Response, StatusCode};
use hyper::Body;
use patch_db::{DbHandle, LockReceipt, LockType};
use reqwest::Url;
use rpc_toolkit::command;
use rpc_toolkit::yajrc::RpcError;
use rpc_toolkit::{command, Context};
use tokio::fs::{File, OpenOptions};
use tokio::io::{AsyncRead, AsyncSeek, AsyncSeekExt};
use tokio::process::Command;
@@ -478,23 +478,11 @@ pub async fn sideload(
}
.boxed()
});
let cont = RpcContinuation {
created_at: Instant::now(), // TODO
handler,
};
// gc the map
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);
ctx.add_continuation(
guid.clone(),
RpcContinuation::rest(handler, Duration::from_secs(30)),
)
.await;
Ok(guid)
}
@@ -537,12 +525,7 @@ async fn cli_install(
let body = Body::wrap_stream(tokio_util::io::ReaderStream::new(file));
let res = ctx
.client
.post(format!(
"{}://{}/rest/rpc/{}",
ctx.protocol(),
ctx.host(),
guid
))
.post(format!("{}/rest/rpc/{}", ctx.base_url, guid,))
.header(CONTENT_LENGTH, content_length)
.body(body)
.send()

View File

@@ -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::time::{Duration, UNIX_EPOCH};
use chrono::{DateTime, Utc};
use clap::ArgMatches;
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::yajrc::RpcError;
use serde::{Deserialize, Serialize};
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_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 crate::context::{CliContext, RpcContext};
use crate::core::rpc_continuations::{RequestGuid, RpcContinuation};
use crate::error::ResultExt;
use crate::procedure::docker::DockerProcedure;
use crate::s9pk::manifest::PackageId;
use crate::util::serde::Reversible;
use crate::Error;
use crate::util::{display_none, serde::Reversible};
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)]
#[serde(rename_all = "kebab-case")]
@@ -25,6 +120,12 @@ pub struct LogResponse {
start_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)]
pub struct LogEntry {
@@ -111,38 +212,145 @@ pub enum LogSource {
Container(PackageId),
}
pub fn display_logs(all: LogResponse, _: &ArgMatches) {
for entry in all.entries.iter() {
println!("{}", entry);
}
}
#[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(
#[arg] id: PackageId,
#[arg] limit: Option<usize>,
#[arg] cursor: Option<String>,
#[arg] before_flag: Option<bool>,
#[arg(short = 'l', long = "limit")] limit: Option<usize>,
#[arg(short = 'c', long = "cursor")] cursor: Option<String>,
#[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> {
Ok(fetch_logs(
LogSource::Container(id),
limit,
cursor,
before_flag.unwrap_or(false),
)
.await?)
fetch_logs(LogSource::Container(id), limit, cursor, before).await
}
#[command(rpc_only, rename = "follow", display(display_none))]
pub async fn logs_follow(
#[context] ctx: RpcContext,
#[parent_data] (id, limit, _, _, _): (PackageId, Option<usize>, Option<String>, bool, bool),
) -> Result<LogFollowResponse, Error> {
follow_logs(ctx, LogSource::Container(id), limit).await
}
#[instrument]
pub async fn fetch_logs(
id: LogSource,
pub async fn cli_logs_generic_nofollow(
ctx: CliContext,
method: &str,
id: Option<PackageId>,
limit: Option<usize>,
cursor: Option<String>,
before_flag: bool,
) -> Result<LogResponse, Error> {
let mut cmd = Command::new("journalctl");
before: bool,
) -> Result<(), RpcError> {
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-fields=MESSAGE");
@@ -163,17 +371,16 @@ pub async fn fetch_logs(
}
};
let cursor_formatted = format!("--after-cursor={}", cursor.clone().unwrap_or("".to_owned()));
let mut get_prev_logs_and_reverse = false;
let cursor_formatted = format!("--after-cursor={}", cursor.clone().unwrap_or(""));
if cursor.is_some() {
cmd.arg(&cursor_formatted);
if before_flag {
get_prev_logs_and_reverse = true;
}
}
if get_prev_logs_and_reverse {
if before {
cmd.arg("--reverse");
}
}
if follow {
cmd.arg("--follow");
}
let mut child = cmd.stdout(Stdio::piped()).spawn()?;
let out = BufReader::new(
@@ -185,7 +392,7 @@ pub async fn fetch_logs(
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))
.and_then(|s| {
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 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()?;
start_cursor = Some(cursor);
entries.push(entry);
}
let (mut end_cursor, entries) = deserialized_entries
let (mut end_cursor, entries) = stream
.try_fold(
(start_cursor.clone(), entries),
|(_, mut acc), entry| async move {
@@ -215,7 +443,7 @@ pub async fn fetch_logs(
.await?;
let mut entries = Reversible::new(entries);
// reverse again so output is always in increasing chronological order
if get_prev_logs_and_reverse {
if cursor.is_some() && before {
entries.reverse();
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]
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,
)
.await
.unwrap();
let serialized = serde_json::to_string_pretty(&response).unwrap();
println!("{}", serialized);
let mut cmd = Command::new("journalctl");
cmd.kill_on_drop(true);
cmd.arg("-f");
cmd.arg("CONTAINER_NAME=hello-world.embassy");
let mut child = cmd.stdout(Stdio::piped()).spawn().unwrap();
let out = BufReader::new(
child
.stdout
.take()
.ok_or_else(|| Error::new(eyre!("No stdout available"), crate::ErrorKind::Journald))
.unwrap(),
);
let mut journalctl_entries = LinesStream::new(out.lines());
while let Some(line) = journalctl_entries.try_next().await.unwrap() {
dbg!(line);
}
}

View File

@@ -1,49 +1,126 @@
use std::fmt;
use color_eyre::eyre::eyre;
use futures::FutureExt;
use rpc_toolkit::command;
use rpc_toolkit::yajrc::RpcError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use tokio::sync::broadcast::Receiver;
use tokio::sync::RwLock;
use tracing::instrument;
use crate::context::RpcContext;
use crate::context::{CliContext, RpcContext};
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::util::display_none;
use crate::util::serde::{display_serializable, IoFormat};
use crate::{Error, ErrorKind};
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(
#[arg] limit: Option<usize>,
#[arg] cursor: Option<String>,
#[arg] before_flag: Option<bool>,
#[arg(short = 'l', long = "limit")] limit: Option<usize>,
#[arg(short = 'c', long = "cursor")] cursor: Option<String>,
#[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> {
Ok(fetch_logs(
LogSource::Service(SYSTEMD_UNIT),
limit,
cursor,
before_flag.unwrap_or(false),
)
.await?)
fetch_logs(LogSource::Service(SYSTEMD_UNIT), limit, cursor, before).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(
#[arg] limit: Option<usize>,
#[arg] cursor: Option<String>,
#[arg] before_flag: Option<bool>,
#[arg(short = 'l', long = "limit")] limit: Option<usize>,
#[arg(short = 'c', long = "cursor")] cursor: Option<String>,
#[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> {
Ok(fetch_logs(
LogSource::Kernel,
limit,
cursor,
before_flag.unwrap_or(false),
)
.await?)
fetch_logs(LogSource::Service(SYSTEMD_UNIT), limit, cursor, before).await
}
#[command(rpc_only, rename = "follow", display(display_none))]
pub async fn kernel_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
}
#[derive(Serialize, Deserialize)]

View File

@@ -427,7 +427,8 @@
}
},
"cli": {
"schematicCollections": ["@ionic/angular-toolkit"]
"schematicCollections": ["@ionic/angular-toolkit"],
"analytics": false
},
"schematics": {
"@ionic/angular-toolkit:component": {

View File

@@ -9,39 +9,49 @@
<ion-content
[scrollEvents]="true"
(ionScroll)="scrollEvent()"
style="height: 100%;"
id="ion-content"
(ionScrollEnd)="scrollEnd()"
class="ion-padding"
>
<ion-infinite-scroll id="scroller" *ngIf="!loading && needInfinite" position="top" threshold="0" (ionInfinite)="loadData($event)">
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
<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>
<div id="container">
<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 id="template" style="white-space: pre-line"></div>
</div>
<div id="bottom-div"></div>
<div
*ngIf="!loading"
[ngStyle]="{
'position': 'fixed',
'bottom': '50px',
'right': isOnBottom ? '-52px' : '30px',
'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-button>
</div>
</ion-content>

View File

@@ -1,7 +1,8 @@
import { Component, ViewChild } from '@angular/core'
import { IonContent } from '@ionic/angular'
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 = new Convert({
bg: 'transparent',
@@ -15,122 +16,80 @@ var convert = new Convert({
export class LogsPage {
@ViewChild(IonContent) private content?: IonContent
loading = true
loadingMore = false
needInfinite = true
startCursor?: string
endCursor?: string
limit = 200
isOnBottom = true
constructor(private readonly api: ApiService) {}
constructor(
private readonly api: ApiService,
private readonly errToast: ErrorToastService,
) {}
ngOnInit() {
this.getLogs()
}
async getLogs() {
try {
// 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']
}
async ngOnInit() {
await this.getLogs()
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')
scrollEnd() {
const bottomDiv = document.getElementById('bottom-div')
this.isOnBottom =
!!buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight
!!bottomDiv &&
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
}
scrollToBottom() {
this.content?.scrollToBottom(500)
}
async loadData(e: any): Promise<void> {
async doInfinite(e: any): Promise<void> {
await this.getLogs()
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)
}
}
}

View File

@@ -1,9 +1,11 @@
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
export abstract class ApiService {
abstract getError(): Promise<GetErrorRes>
abstract restart(): Promise<void>
abstract forgetDrive(): Promise<void>
abstract repairDisk(): Promise<void>
abstract getLogs(params: GetLogsReq): Promise<GetLogsRes>
abstract getLogs(params: ServerLogsReq): Promise<LogsRes>
}
export interface GetErrorRes {
@@ -11,21 +13,3 @@ export interface GetErrorRes {
message: 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
}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core'
import { HttpService } from '../http.service'
import { ApiService, GetErrorRes, GetLogsReq, GetLogsRes } from './api.service'
import { HttpService } from '@start9labs/shared'
import { ApiService, GetErrorRes } from './api.service'
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
@Injectable()
export class LiveApiService extends ApiService {
@@ -36,8 +37,8 @@ export class LiveApiService extends ApiService {
})
}
getLogs(params: GetLogsReq): Promise<GetLogsRes> {
return this.http.rpcRequest<GetLogsRes>({
getLogs(params: ServerLogsReq): Promise<LogsRes> {
return this.http.rpcRequest<LogsRes>({
method: 'diagnostic.logs',
params,
})

View File

@@ -1,12 +1,7 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import {
ApiService,
GetErrorRes,
GetLogsReq,
GetLogsRes,
Log,
} from './api.service'
import { ApiService, GetErrorRes } from './api.service'
import { LogsRes, ServerLogsReq, Log } from '@start9labs/shared'
@Injectable()
export class MockApiService extends ApiService {
@@ -35,7 +30,7 @@ export class MockApiService extends ApiService {
await pauseFor(1000)
}
async getLogs(params: GetLogsReq): Promise<GetLogsRes> {
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
await pauseFor(1000)
let entries: Log[]
if (Math.random() < 0.2) {

View File

@@ -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

View File

@@ -5,7 +5,6 @@ import { HttpClientModule } from '@angular/common/http'
import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service'
import { HttpService } from './services/api/http.service'
import {
IonicModule,
IonicRouteStrategy,
@@ -45,14 +44,7 @@ const { useMocks } = require('../../../../config.json') as WorkspaceConfig
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{
provide: ApiService,
useFactory: (http: HttpService) => {
if (useMocks) {
return new MockApiService()
} else {
return new LiveApiService(http)
}
},
deps: [HttpService],
useClass: useMocks ? MockApiService : LiveApiService,
},
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
],

View File

@@ -1,19 +1,19 @@
import { Injectable } from '@angular/core'
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'
@Injectable({
providedIn: 'root',
})
export class NavGuard implements CanActivate {
constructor (
constructor(
private readonly router: Router,
private readonly httpService: HttpService,
) { }
private readonly encrypted: RPCEncryptedService,
) {}
canActivate (): boolean {
if (this.httpService.productKey) {
canActivate(): boolean {
if (this.encrypted.productKey) {
return true
} else {
this.router.navigateByUrl('product-key')
@@ -26,14 +26,14 @@ export class NavGuard implements CanActivate {
providedIn: 'root',
})
export class RecoveryNavGuard implements CanActivate {
constructor (
constructor(
private readonly router: Router,
private readonly httpService: HttpService,
private readonly encrypted: RPCEncryptedService,
private readonly stateService: StateService,
) { }
) {}
canActivate (): boolean {
if (this.httpService.productKey || !this.stateService.hasProductKey) {
canActivate(): boolean {
if (this.encrypted.productKey || !this.stateService.hasProductKey) {
return true
} else {
this.router.navigateByUrl('product-key')

View File

@@ -1,7 +1,7 @@
import { Component, Input, ViewChild } from '@angular/core'
import { IonInput, LoadingController, ModalController } from '@ionic/angular'
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({
selector: 'prod-key-modal',
@@ -20,7 +20,7 @@ export class ProdKeyModal {
private readonly modalController: ModalController,
private readonly apiService: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly httpService: HttpService,
private readonly encrypted: RPCEncryptedService,
) {}
ngAfterViewInit() {
@@ -37,11 +37,11 @@ export class ProdKeyModal {
try {
await this.apiService.set02XDrive(this.target.logicalname)
this.httpService.productKey = this.productKey
this.encrypted.productKey = this.productKey
await this.apiService.verifyProductKey()
this.modalController.dismiss({ productKey: this.productKey }, 'success')
} catch (e) {
this.httpService.productKey = undefined
this.encrypted.productKey = undefined
this.error = 'Invalid Product Key'
} finally {
loader.dismiss()

View File

@@ -1,7 +1,7 @@
import { Component, ViewChild } from '@angular/core'
import { IonInput, LoadingController, NavController } from '@ionic/angular'
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'
@Component({
@@ -19,7 +19,7 @@ export class ProductKeyPage {
private readonly stateService: StateService,
private readonly apiService: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly httpService: HttpService,
private readonly encrypted: RPCEncryptedService,
) {}
ionViewDidEnter() {
@@ -35,7 +35,7 @@ export class ProductKeyPage {
await loader.present()
try {
this.httpService.productKey = this.productKey
this.encrypted.productKey = this.productKey
await this.apiService.verifyProductKey()
if (this.stateService.isMigrating) {
await this.navCtrl.navigateForward(`/loading`)
@@ -44,7 +44,7 @@ export class ProductKeyPage {
}
} catch (e) {
this.error = 'Invalid Product Key'
this.httpService.productKey = undefined
this.encrypted.productKey = undefined
} finally {
loader.dismiss()
}

View File

@@ -8,7 +8,7 @@
style="font-size: 80px"
name="checkmark-circle-outline"
></ion-icon>
<ion-card-title>Setup Complete!</ion-card-title>
<ion-card-title>Setup Complete</ion-card-title>
</ion-card-header>
<ion-card-content>
<br />
@@ -17,61 +17,21 @@
>
<h2>You can now safely unplug your backup drive.</h2>
</ng-template>
<!-- Tor Instructions -->
<div (click)="toggleTor()" class="toggle-label">
<h2>Tor Instructions:</h2>
<ion-icon
name="chevron-down-outline"
[ngStyle]="{
'transform': torOpen ? 'rotate(-90deg)' : 'rotate(0deg)',
'transition': 'transform 0.4s ease-out'
}"
></ion-icon>
</div>
<h2>
You have successully claimed your Embassy! You can now access your
device using the methods below.
</h2>
<br />
<div
[ngStyle]="{
'overflow' : 'hidden',
'max-height': torOpen ? '500px' : '0px',
'transition': 'max-height 0.4s ease-out'
}"
>
<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
>.
<b>Note:</b> embassy.local was for setup purposes only, it will no
longer work.
</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 -->
<div (click)="toggleLan()" class="toggle-label">
<h2>LAN Instructions (Slightly Advanced):</h2>
<h2>From Home (LAN)</h2>
<ion-icon
name="chevron-down-outline"
[ngStyle]="{
@@ -89,49 +49,21 @@
}"
>
<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>
For step-by-step instructions, click
<a
href="https://start9.com/latest/user-manual/connecting/connecting-lan"
target="_blank"
rel="noreferrer"
><b>here</b></a
>.
Visit the address below when you are conncted to the same WiFi
or Local Area Network (LAN) as your Embassy:
</p>
<p>
<b
>Please note, once setup is complete, the embassy.local
address will no longer connect to your Embassy.</b
<ion-item
lines="none"
color="dark"
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">
<code
><ion-text color="light">{{ lanAddress }}</ion-text></code
><ion-text color="light"
><b>{{ lanAddress }}</b></ion-text
></code
>
</ion-label>
<ion-button
@@ -142,10 +74,96 @@
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</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 style="padding-bottom: 24px; border-bottom: solid 1px"></div>
<br />
</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">
<ion-button
color="light"

View File

@@ -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 { ErrorToastService } from '@start9labs/shared'
import {
copyToClipboard,
DownloadHTMLService,
ErrorToastService,
} from '@start9labs/shared'
import { StateService } from 'src/app/services/state.service'
@Component({
selector: 'success',
templateUrl: 'success.page.html',
styleUrls: ['success.page.scss'],
providers: [DownloadHTMLService],
})
export class SuccessPage {
@Output() onDownload = new EventEmitter()
torOpen = true
torOpen = false
lanOpen = false
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly toastCtrl: ToastController,
private readonly errCtrl: ErrorToastService,
private readonly stateService: StateService,
private readonly downloadHtml: DownloadHTMLService,
) {}
get recoverySource() {
@@ -34,7 +42,7 @@ export class SuccessPage {
async ngAfterViewInit() {
try {
await this.stateService.completeEmbassy()
document
this.document
.getElementById('install-cert')
?.setAttribute(
'href',
@@ -48,7 +56,7 @@ export class SuccessPage {
}
async copy(address: string): Promise<void> {
const success = await this.copyToClipboard(address)
const success = await copyToClipboard(address)
const message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
@@ -70,49 +78,24 @@ export class SuccessPage {
}
installCert() {
document.getElementById('install-cert')?.click()
this.document.getElementById('install-cert')?.click()
}
download() {
const torAddress = document.getElementById('tor-addr')
const lanAddress = document.getElementById('lan-addr')
const torAddress = this.document.getElementById('tor-addr')
const lanAddress = this.document.getElementById('lan-addr')
if (torAddress) torAddress.innerHTML = this.stateService.torAddress
if (lanAddress) lanAddress.innerHTML = this.stateService.lanAddress
document
this.document
.getElementById('cert')
?.setAttribute(
'href',
'data:application/x-x509-ca-cert;base64,' +
encodeURIComponent(this.stateService.cert),
)
let html = document.getElementById('downloadable')?.innerHTML || ''
const filename = 'embassy-info.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
let html = this.document.getElementById('downloadable')?.innerHTML || ''
this.downloadHtml.download('embassy-info.html', html)
}
}

View File

@@ -13,42 +13,42 @@ export abstract class ApiService {
abstract setupComplete(): Promise<SetupEmbassyRes> // setup.complete
}
export interface GetStatusRes {
export type GetStatusRes = {
'product-key': boolean
migrating: boolean
}
export interface ImportDriveReq {
export type ImportDriveReq = {
guid: string
'embassy-password': string
}
export interface SetupEmbassyReq {
export type SetupEmbassyReq = {
'embassy-logicalname': string
'embassy-password': string
'recovery-source': CifsRecoverySource | DiskRecoverySource | null
'recovery-password': string | null
}
export interface SetupEmbassyRes {
export type SetupEmbassyRes = {
'tor-address': string
'lan-address': string
'root-ca': string
}
export interface EmbassyOSRecoveryInfo {
export type EmbassyOSRecoveryInfo = {
version: string
full: boolean
'password-hash': string | null
'wrapped-key': string | null
}
export interface DiskListResponse {
export type DiskListResponse = {
disks: DiskInfo[]
reconnect: string[]
}
export interface DiskBackupTarget {
export type DiskBackupTarget = {
vendor: string | null
model: string | null
logicalname: string | null
@@ -58,7 +58,7 @@ export interface DiskBackupTarget {
'embassy-os': EmbassyOSRecoveryInfo | null
}
export interface CifsBackupTarget {
export type CifsBackupTarget = {
hostname: string
path: string
username: string
@@ -66,12 +66,12 @@ export interface CifsBackupTarget {
'embassy-os': EmbassyOSRecoveryInfo | null
}
export interface DiskRecoverySource {
export type DiskRecoverySource = {
type: 'disk'
logicalname: string // partition logicalname
}
export interface CifsRecoverySource {
export type CifsRecoverySource = {
type: 'cifs'
hostname: string
path: string
@@ -79,7 +79,7 @@ export interface CifsRecoverySource {
password: string | null
}
export interface DiskInfo {
export type DiskInfo = {
logicalname: string
vendor: string | null
model: string | null
@@ -88,13 +88,13 @@ export interface DiskInfo {
guid: string | null // cant back up if guid exists
}
export interface RecoveryStatusRes {
export type RecoveryStatusRes = {
'bytes-transferred': number
'total-bytes': number
complete: boolean
}
export interface PartitionInfo {
export type PartitionInfo = {
logicalname: string
label: string | null
capacity: number

View File

@@ -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)
}

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core'
import { HttpService } from '@start9labs/shared'
import {
ApiService,
CifsRecoverySource,
@@ -11,79 +12,70 @@ import {
SetupEmbassyReq,
SetupEmbassyRes,
} from './api.service'
import { HttpService } from './http.service'
import { RPCEncryptedService } from '../rpc-encrypted.service'
@Injectable({
providedIn: 'root',
})
export class LiveApiService extends ApiService {
constructor(private readonly http: HttpService) {
constructor(
private readonly unencrypted: HttpService,
private readonly encrypted: RPCEncryptedService,
) {
super()
}
// ** UNENCRYPTED **
async getStatus() {
return this.http.rpcRequest<GetStatusRes>(
{
return this.unencrypted.rpcRequest<GetStatusRes>({
method: 'setup.status',
params: {},
},
false,
)
})
}
async getDrives() {
return this.http.rpcRequest<DiskListResponse>(
{
return this.unencrypted.rpcRequest<DiskListResponse>({
method: 'setup.disk.list',
params: {},
},
false,
)
})
}
async set02XDrive(logicalname: string) {
return this.http.rpcRequest<void>(
{
return this.unencrypted.rpcRequest<void>({
method: 'setup.recovery.v2.set',
params: { logicalname },
},
false,
)
})
}
async getRecoveryStatus() {
return this.http.rpcRequest<RecoveryStatusRes>(
{
return this.unencrypted.rpcRequest<RecoveryStatusRes>({
method: 'setup.recovery.status',
params: {},
},
false,
)
})
}
// ** ENCRYPTED **
async verifyCifs(source: CifsRecoverySource) {
source.path = source.path.replace('/\\/g', '/')
return this.http.rpcRequest<EmbassyOSRecoveryInfo>({
return this.encrypted.rpcRequest<EmbassyOSRecoveryInfo>({
method: 'setup.cifs.verify',
params: source as any,
params: source,
})
}
async verifyProductKey() {
return this.http.rpcRequest<void>({
return this.encrypted.rpcRequest<void>({
method: 'echo',
params: { message: 'hello' },
})
}
async importDrive(params: ImportDriveReq) {
const res = await this.http.rpcRequest<SetupEmbassyRes>({
const res = await this.encrypted.rpcRequest<SetupEmbassyRes>({
method: 'setup.attach',
params: params as any,
params,
})
return {
@@ -99,9 +91,9 @@ export class LiveApiService extends ApiService {
].path.replace('/\\/g', '/')
}
const res = await this.http.rpcRequest<SetupEmbassyRes>({
const res = await this.encrypted.rpcRequest<SetupEmbassyRes>({
method: 'setup.execute',
params: setupInfo as any,
params: setupInfo,
})
return {
@@ -111,7 +103,7 @@ export class LiveApiService extends ApiService {
}
async setupComplete() {
const res = await this.http.rpcRequest<SetupEmbassyRes>({
const res = await this.encrypted.rpcRequest<SetupEmbassyRes>({
method: 'setup.complete',
params: {},
})

View File

@@ -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)
}

View File

@@ -1,7 +1,7 @@
<ion-grid class="full-height">
<ion-row class="ion-align-items-center ion-text-center full-height">
<ion-col>
<ion-spinner name="lines" color="warning"></ion-spinner>
<ion-spinner color="warning"></ion-spinner>
<p>{{ text }}</p>
</ion-col>
</ion-row>

View File

@@ -6,9 +6,9 @@ export * from './classes/http-error'
export * from './classes/rpc-error'
export * from './components/markdown/markdown.component'
export * from './components/markdown/markdown.module'
export * from './components/text-spinner/text-spinner.component.module'
export * from './components/markdown/markdown.component.module'
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.module'
@@ -27,13 +27,17 @@ export * from './pipes/unit-conversion/unit-conversion.module'
export * from './pipes/unit-conversion/unit-conversion.pipe'
export * from './services/destroy.service'
export * from './services/download-html.service'
export * from './services/emver.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/url'
export * from './types/workspace-config'
export * from './util/copy-to-clipboard'
export * from './util/get-pkg-id'
export * from './util/misc.util'
export * from './util/to-local-iso-string'

View File

@@ -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)
}
}

View 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
}

View 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
}

View 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
}

View File

@@ -6,8 +6,7 @@
"outDir": "../../out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
"inlineSources": true
},
"exclude": ["src/test.ts", "**/*.spec.ts"]
}

View File

@@ -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" />
</a>
<div class="divider"></div>

View File

@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { AlertController } from '@ionic/angular'
import { ConfigService } from '../../services/config.service'
import { LocalStorageService } from '../../services/local-storage.service'
import { EOSService } from '../../services/eos.service'
import { ApiService } from '../../services/api/embassy-api.service'
@@ -62,7 +61,6 @@ export class MenuComponent {
.pipe(map(pkgs => pkgs.length))
constructor(
private readonly config: ConfigService,
private readonly alertCtrl: AlertController,
private readonly embassyApi: ApiService,
private readonly authService: AuthService,
@@ -73,12 +71,6 @@ export class MenuComponent {
private readonly marketplaceService: MarketplaceService,
) {}
get href(): string {
return this.config.isTor()
? 'http://privacy34kn4ez3y3nijweec6w4g54i3g54sdv7r5mr6soma3w4begyd.onion'
: 'https://start9.com'
}
async presentAlertLogout() {
const alert = await this.alertCtrl.create({
header: 'Caution',

View File

@@ -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>

View File

@@ -1,12 +1,13 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
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'
@NgModule({
declarations: [LogsPage],
imports: [CommonModule, IonicModule, TextSpinnerComponentModule],
exports: [LogsPage],
declarations: [LogsComponent],
imports: [CommonModule, IonicModule, TextSpinnerComponentModule, FormsModule],
exports: [LogsComponent],
})
export class LogsPageModule {}
export class LogsComponentModule {}

View File

@@ -0,0 +1,5 @@
#container {
padding-bottom: 16px;
font-family: monospace;
white-space: pre-line;
}

View 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>&nbsp;&nbsp;${convert.toHtml(entry.message)}`,
)
.join('<br />')
}
}

View File

@@ -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>

View File

@@ -1,3 +0,0 @@
#container {
padding-bottom: 16px;
}

View File

@@ -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)
}
}
}

View File

@@ -1,22 +1,18 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-title>Execution Complete</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()" class="enter-click">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Execution Complete</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
<ion-label>
<h2>{{ actionRes.message }}</h2>
</ion-label>
</ion-item>
<ion-content class="ion-padding">
<h2 class="ion-padding">{{ actionRes.message }}</h2>
<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">
<qr-code [value]="actionRes.value" size="240"></qr-code>
</div>

View File

@@ -1,7 +1,7 @@
import { Component, Input } from '@angular/core'
import { ModalController, ToastController } from '@ionic/angular'
import { ActionResponse } from 'src/app/services/api/api.types'
import { copyToClipboard } from 'src/app/util/web.util'
import { copyToClipboard } from '@start9labs/shared'
@Component({
selector: 'action-success',

View File

@@ -25,7 +25,7 @@
</ion-item>
<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">
<h2
*ngIf="!configForm.dirty"
@@ -81,7 +81,7 @@
</ion-item>
<!-- no config -->
<ion-item *ngIf="!hasConfig">
<ion-item *ngIf="!configForm">
<ion-label>
<p>
No config options for {{ pkg.manifest.title }} {{
@@ -91,7 +91,11 @@
</ion-item>
<!-- has config -->
<form *ngIf="hasConfig" [formGroup]="configForm" novalidate>
<form
*ngIf="configForm && configSpec"
[formGroup]="configForm"
novalidate
>
<form-object
[objectSpec]="configSpec"
[formGroup]="configForm"
@@ -107,7 +111,7 @@
<ion-footer>
<ion-toolbar>
<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-icon slot="start" name="refresh"></ion-icon>
Reset Defaults
@@ -115,7 +119,7 @@
</ion-buttons>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
*ngIf="hasConfig"
*ngIf="configForm"
fill="solid"
color="primary"
[disabled]="saving"
@@ -126,7 +130,7 @@
Save
</ion-button>
<ion-button
*ngIf="!hasConfig"
*ngIf="!configForm"
fill="solid"
color="dark"
(click)="dismiss()"

View File

@@ -34,19 +34,18 @@ import { Breakages } from 'src/app/services/api/api.types'
export class AppConfigPage {
@Input() pkgId!: string
@Input()
dependentInfo?: DependentInfo
@Input() dependentInfo?: DependentInfo
pkg!: PackageDataEntry
loadingText!: string
configSpec!: ConfigSpec
configForm!: UntypedFormGroup
loadingText = ''
configSpec?: ConfigSpec
configForm?: UntypedFormGroup
original?: object // only if existing config
diff?: string[] // only if dependent info
loading = true
hasConfig = false
hasNewOptions = false
saving = false
loadingError: string | IonicSafeString = ''
@@ -64,9 +63,8 @@ export class AppConfigPage {
async ngOnInit() {
try {
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 patch: Operation[] | undefined
@@ -118,7 +116,7 @@ export class AppConfigPage {
}
async dismiss() {
if (this.configForm.dirty) {
if (this.configForm?.dirty) {
this.presentAlertUnsaved()
} else {
this.modalCtrl.dismiss()
@@ -126,9 +124,9 @@ export class AppConfigPage {
}
async tryConfigure() {
convertValuesRecursive(this.configSpec, this.configForm)
convertValuesRecursive(this.configSpec!, this.configForm!)
if (this.configForm.invalid) {
if (this.configForm!.invalid) {
document
.getElementsByClassName('validation-error')[0]
?.scrollIntoView({ behavior: 'smooth' })
@@ -153,7 +151,7 @@ export class AppConfigPage {
try {
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config: this.configForm.value,
config: this.configForm!.value,
})
if (isEmptyObject(breakages)) {
@@ -186,7 +184,7 @@ export class AppConfigPage {
try {
await this.embassyApi.setPackageConfig({
id: this.pkgId,
config: this.configForm.value,
config: this.configForm!.value,
})
this.modalCtrl.dismiss()
} catch (e: any) {
@@ -304,11 +302,11 @@ export class AppConfigPage {
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') {
const prevPath = arrPath.slice(0, arrPath.length - 1)
this.configForm.get(prevPath)?.markAsDirty()
this.configForm!.get(prevPath)?.markAsDirty()
}
})
}

View File

@@ -1,14 +1,13 @@
import { Component, Input } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
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 {
InstalledPackageDataEntry,
InterfaceDef,
} from 'src/app/services/patch-db/data-model'
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 { getPackage } from '../../../util/get-package-data'

View File

@@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppLogsPage } from './app-logs.page'
import { SharedPipesModule } from '@start9labs/shared'
import { LogsPageModule } from 'src/app/components/logs/logs.module'
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
const routes: Routes = [
{
@@ -18,8 +17,7 @@ const routes: Routes = [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
LogsPageModule,
LogsComponentModule,
],
declarations: [AppLogsPage],
})

View File

@@ -1,15 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Logs</ion-title>
<ion-button slot="end" fill="clear" size="small" (click)="copy()">
<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>
<logs
[fetchLogs]="fetchLogs()"
[followLogs]="followLogs()"
[defaultBack]="'/services/' + pkgId"
title="Service Logs"
class="ion-page"
></logs>

View File

@@ -1,9 +1,8 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { ToastController } from '@ionic/angular'
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({
selector: 'app-logs',
@@ -16,39 +15,23 @@ export class AppLogsPage {
constructor(
private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService,
private readonly toastCtrl: ToastController,
) {}
fetchFetchLogs() {
return async (params: {
before_flag?: boolean
limit?: number
cursor?: string
}) => {
followLogs() {
return async (params: RR.FollowServerLogsReq) => {
return this.embassyApi.followPackageLogs({
id: this.pkgId,
...params,
})
}
}
fetchLogs() {
return async (params: RR.GetServerLogsReq) => {
return this.embassyApi.getPackageLogs({
id: this.pkgId,
before_flag: params.before_flag,
cursor: params.cursor,
limit: params.limit,
...params,
})
}
}
async copy(): Promise<void> {
const logs = document
.getElementById('template')
?.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()
}
}

View File

@@ -1,7 +1,6 @@
import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { copyToClipboard } from 'src/app/util/web.util'
import {
AlertController,
IonBackButtonDelegate,
@@ -13,7 +12,12 @@ import { PackageProperties } from 'src/app/util/properties.util'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
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 { map, takeUntil } from 'rxjs/operators'

View File

@@ -9,31 +9,46 @@ const routes: Routes = [
},
{
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',
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',
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',
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',
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',
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',
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)],
exports: [RouterModule],
})
export class AppsRoutingModule { }
export class AppsRoutingModule {}

View File

@@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { KernelLogsPage } from './kernel-logs.page'
import { SharedPipesModule } from '@start9labs/shared'
import { LogsPageModule } from 'src/app/components/logs/logs.module'
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
const routes: Routes = [
{
@@ -18,8 +17,7 @@ const routes: Routes = [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
LogsPageModule,
LogsComponentModule,
],
declarations: [KernelLogsPage],
})

View File

@@ -1,17 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title>Kernel Logs</ion-title>
<ion-buttons slot="end">
<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>
<logs
[fetchLogs]="fetchLogs()"
[followLogs]="followLogs()"
defaultBack="embassy"
title="Kernel Logs"
class="ion-page"
></logs>

View File

@@ -1,7 +1,6 @@
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 { copyToClipboard, strip } from 'src/app/util/web.util'
@Component({
selector: 'kernel-logs',
@@ -9,40 +8,17 @@ import { copyToClipboard, strip } from 'src/app/util/web.util'
styleUrls: ['./kernel-logs.page.scss'],
})
export class KernelLogsPage {
constructor(
private readonly embassyApi: ApiService,
private readonly toastCtrl: ToastController,
) {}
constructor(private readonly embassyApi: ApiService) {}
fetchFetchLogs() {
return async (params: {
before_flag?: boolean
limit?: number
cursor?: string
}) => {
return this.embassyApi.getKernelLogs({
before_flag: params.before_flag,
cursor: params.cursor,
limit: params.limit,
})
followLogs() {
return async (params: RR.FollowServerLogsReq) => {
return this.embassyApi.followKernelLogs(params)
}
}
async copy(): Promise<void> {
const logs = document
.getElementById('template')
?.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()
fetchLogs() {
return async (params: RR.GetServerLogsReq) => {
return this.embassyApi.getKernelLogs(params)
}
}
}

View File

@@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { ServerLogsPage } from './server-logs.page'
import { SharedPipesModule } from '@start9labs/shared'
import { LogsPageModule } from 'src/app/components/logs/logs.module'
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
const routes: Routes = [
{
@@ -18,8 +17,7 @@ const routes: Routes = [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
LogsPageModule,
LogsComponentModule,
],
declarations: [ServerLogsPage],
})

View File

@@ -1,17 +1,7 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title>OS Logs</ion-title>
<ion-buttons slot="end">
<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>
<logs
[fetchLogs]="fetchLogs()"
[followLogs]="followLogs()"
defaultBack="embassy"
title="OS Logs"
class="ion-page"
></logs>

View File

@@ -1,7 +1,6 @@
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 { copyToClipboard, strip } from 'src/app/util/web.util'
@Component({
selector: 'server-logs',
@@ -9,40 +8,17 @@ import { copyToClipboard, strip } from 'src/app/util/web.util'
styleUrls: ['./server-logs.page.scss'],
})
export class ServerLogsPage {
constructor(
private readonly embassyApi: ApiService,
private readonly toastCtrl: ToastController,
) {}
constructor(private readonly embassyApi: ApiService) {}
fetchFetchLogs() {
return async (params: {
before_flag?: boolean
limit?: number
cursor?: string
}) => {
return this.embassyApi.getServerLogs({
before_flag: params.before_flag,
cursor: params.cursor,
limit: params.limit,
})
followLogs() {
return async (params: RR.FollowServerLogsReq) => {
return this.embassyApi.followServerLogs(params)
}
}
async copy(): Promise<void> {
const logs = document
.getElementById('template')
?.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()
fetchLogs() {
return async (params: RR.GetServerLogsReq) => {
return this.embassyApi.getServerLogs(params)
}
}
}

View File

@@ -1,8 +1,8 @@
import { Component } from '@angular/core'
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 { ConfigService } from 'src/app/services/config.service'
import { copyToClipboard } from '@start9labs/shared'
@Component({
selector: 'server-specs',

View File

@@ -7,16 +7,11 @@ import {
PackageState,
ServerStatusInfo,
} from 'src/app/services/patch-db/data-model'
import {
Log,
Metric,
RR,
NotificationLevel,
ServerNotifications,
} from './api.types'
import { Metric, RR, NotificationLevel, ServerNotifications } from './api.types'
import { BTC_ICON, LND_ICON, PROXY_ICON } from './api-icons'
import { MarketplacePkg } from '@start9labs/marketplace'
import { Log } from '@start9labs/shared'
export module Mock {
export const ServerUpdated: ServerStatusInfo = {
@@ -955,7 +950,7 @@ export module Mock {
{
timestamp: '2019-12-26T14:21:30.872Z',
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',
@@ -1439,7 +1434,7 @@ export module Mock {
'bitcoin-node': {
name: 'Bitcoin Node Settings',
type: 'union',
description: 'The node settings',
description: 'Options<ul><li>Item 1</li><li>Item 2</li></ul>',
default: 'internal',
warning: 'Careful changing this',
tag: {

View File

@@ -7,6 +7,7 @@ import {
DependencyError,
Manifest,
} from 'src/app/services/patch-db/data-model'
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
export module RR {
// DB
@@ -28,13 +29,15 @@ export module RR {
// server
export type GetServerLogsReq = {
cursor?: string
before_flag?: boolean
limit?: number
}
export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs
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 GetServerMetricsRes = Metrics
@@ -160,20 +163,12 @@ export module RR {
export type GetPackagePropertiesRes<T extends number> =
PackagePropertiesVersioned<T>
export type LogsRes = {
entries: Log[]
'start-cursor'?: string
'end-cursor'?: string
}
export type GetPackageLogsReq = {
id: string
cursor?: string
before_flag?: boolean
limit?: number
} // package.logs
export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = LogsRes
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
export type FollowPackageLogsRes = FollowServerLogsRes
export type GetPackageMetricsReq = { id: string } // package.metrics
export type GetPackageMetricsRes = Metric
@@ -238,7 +233,7 @@ export module RR {
spec: ConfigSpec
}
export interface SideloadPackageReq {
export type SideloadPackageReq = {
manifest: Manifest
icon: string // base64
}
@@ -288,11 +283,6 @@ export interface TaggedDependencyError {
error: DependencyError
}
export interface Log {
timestamp: string
message: string
}
export interface ActionResponse {
message: string
value: string | null

View File

@@ -10,8 +10,9 @@ import {
} from 'patch-db-client'
import { RR } from './api.types'
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 { WebSocketSubjectConfig } from 'rxjs/webSocket'
export abstract class ApiService implements Source<DataModel>, Http<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' })))
}
// websocket
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
// http
// for getting static files: ex icons, instructions, licenses
abstract getStatic(url: string): Promise<string>
@@ -62,6 +71,14 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>
abstract followServerLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
abstract followKernelLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
abstract getServerMetrics(
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes>
@@ -193,6 +210,10 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes>
abstract followPackageLogs(
params: RR.FollowPackageLogsReq,
): Promise<RR.FollowPackageLogsRes>
protected abstract installPackageRaw(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes>
@@ -280,7 +301,7 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
// }
return f(a)
.catch((e: RequestError) => {
.catch((e: UIRequestError) => {
if (e.revision) this.sync$.next(e.revision)
throw e
})
@@ -291,3 +312,5 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
}
}
}
type UIRequestError = RequestError & { revision: Revision }

View File

@@ -1,9 +1,11 @@
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 { RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { ConfigService } from '../config.service'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs'
@Injectable()
export class LiveApiService extends ApiService {
@@ -15,6 +17,10 @@ export class LiveApiService extends ApiService {
; (window as any).rpcClient = this
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return webSocket(config)
}
async getStatic(url: string): Promise<string> {
return this.http.httpRequest({
method: Method.GET,
@@ -39,7 +45,7 @@ export class LiveApiService extends ApiService {
}
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> {
@@ -78,6 +84,18 @@ export class LiveApiService extends ApiService {
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(
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes> {
@@ -252,6 +270,12 @@ export class LiveApiService extends ApiService {
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(
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes> {

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import { pauseFor, Log, LogsRes } from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import { PatchOp, Update, Operation, RemoveOperation } from 'patch-db-client'
import {
@@ -11,13 +11,14 @@ import {
PackageState,
ServerStatus,
} 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 { Mock } from './api.fixures'
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 { mockPatchData } from './mock-patch'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
const PROGRESS: InstallProgress = {
size: 120,
@@ -43,6 +44,16 @@ export class MockApiService extends ApiService {
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> {
await pauseFor(2000)
return markdown
@@ -113,17 +124,8 @@ export class MockApiService extends ApiService {
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
await pauseFor(2000)
let entries: Log[]
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), [])
}
const entries = this.randomLogs(params.limit)
return {
entries,
'start-cursor': 'startCursor',
@@ -135,17 +137,8 @@ export class MockApiService extends ApiService {
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
await pauseFor(2000)
let entries: Log[]
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), [])
}
const entries = this.randomLogs(params.limit)
return {
entries,
'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(
params: RR.GetServerMetricsReq,
): 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(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes> {

View File

@@ -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')
}),
),
)
}

View File

@@ -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) {
let doc = new DOMParser().parseFromString(html, 'text/html')
return doc.body.textContent || ''

View File

@@ -2,7 +2,7 @@
"/rpc/v1": {
"target": "http://<CHANGE_ME>/rpc/v1"
},
"/ws/db": {
"/ws/*": {
"target": "http://<CHANGE_ME>",
"secure": false,
"ws": true

View File

@@ -1,10 +1,12 @@
use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::Duration;
use color_eyre::eyre::{eyre, Context, Error};
use futures::future::BoxFuture;
use futures::FutureExt;
use tokio::fs::File;
use tokio::sync::oneshot;
use tokio::task::{JoinError, JoinHandle};
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()
}
}