mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
Fix/fe bugs 3 (#2943)
* fix typeo in patch db seed * show all registries in updates tab, fix required dependnecy display in marketplace, update browser tab title desc * always show pointer for version select * chore: fix comments * support html in action desc and marketplace long desc, only show qr in action res if qr is true * disable save if smtp creds not edited, show better smtp success message * dont dismiss login spinner until patchDB returns * feat: redesign of service dashboard and interface (#2946) * feat: redesign of service dashboard and interface * chore: comments * re-add setup complete * dibale launch UI when not running, re-style things, rename things * back to 1000 * fix clearnet docs link and require password retype in setup wiz * faster hint display * display dependency ID if title not available * fix migration * better init progress view * fix setup success page by providing VERSION and notifications page fixes * force uninstall from service error page, soft or hard * handle error state better * chore: fixed for install and setup wizards * chore: fix issues (#2949) * enable and disable kiosk mode * minor fixes * fix dependency mounts * dismissable tasks * provide replayId * default if health check success message is null * look for wifi interface too * dash for null user agent in sessions * add disk repair to diagnostic api --------- Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
2
container-runtime/package-lock.json
generated
2
container-runtime/package-lock.json
generated
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"../sdk/dist": {
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.23",
|
||||
"version": "0.4.0-beta.24",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^3.0.0",
|
||||
|
||||
@@ -118,6 +118,7 @@ export class DockerProcedureContainer extends Drop {
|
||||
subpath: volumeMount.path,
|
||||
readonly: volumeMount.readonly,
|
||||
volumeId: volumeMount["volume-id"],
|
||||
filetype: "directory",
|
||||
},
|
||||
})
|
||||
} else if (volumeMount.type === "backup") {
|
||||
|
||||
@@ -1022,6 +1022,7 @@ export class SystemForEmbassy implements System {
|
||||
volumeId: "embassy",
|
||||
subpath: null,
|
||||
readonly: true,
|
||||
filetype: "directory",
|
||||
},
|
||||
})
|
||||
configFile
|
||||
@@ -1168,6 +1169,7 @@ async function updateConfig(
|
||||
volumeId: "embassy",
|
||||
subpath: null,
|
||||
readonly: true,
|
||||
filetype: "directory",
|
||||
},
|
||||
})
|
||||
const remoteConfig = configFile
|
||||
|
||||
@@ -348,6 +348,7 @@ pub struct ClearTaskParams {
|
||||
pub package_id: PackageId,
|
||||
pub replay_id: ReplayId,
|
||||
#[arg(long)]
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use rpc_toolkit::{
|
||||
};
|
||||
|
||||
use crate::context::{CliContext, DiagnosticContext, RpcContext};
|
||||
use crate::disk::repair;
|
||||
use crate::init::SYSTEM_REBUILD_PATH;
|
||||
use crate::prelude::*;
|
||||
use crate::shutdown::Shutdown;
|
||||
@@ -95,6 +96,15 @@ pub fn disk<C: Context>() -> ParentHandler<C> {
|
||||
.no_display()
|
||||
.with_about("Remove disk from filesystem"),
|
||||
)
|
||||
.subcommand("repair", from_fn_async(|_: C| repair()).no_cli())
|
||||
.subcommand(
|
||||
"repair",
|
||||
CallRemoteHandler::<CliContext, _, _>::new(
|
||||
from_fn_async(|_: RpcContext| repair())
|
||||
.no_display()
|
||||
.with_about("Repair disk in the event of corruption"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn forget_disk<C: Context>(_: C) -> Result<(), Error> {
|
||||
|
||||
@@ -3,28 +3,73 @@ use std::path::Path;
|
||||
|
||||
use digest::generic_array::GenericArray;
|
||||
use digest::{Digest, OutputSizeUser};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use ts_rs::TS;
|
||||
|
||||
use super::FileSystem;
|
||||
use crate::prelude::*;
|
||||
use crate::util::io::create_file;
|
||||
|
||||
pub struct Bind<SrcDir: AsRef<Path>> {
|
||||
src_dir: SrcDir,
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum FileType {
|
||||
File,
|
||||
Directory,
|
||||
Infer,
|
||||
}
|
||||
impl<SrcDir: AsRef<Path>> Bind<SrcDir> {
|
||||
pub fn new(src_dir: SrcDir) -> Self {
|
||||
Self { src_dir }
|
||||
|
||||
pub struct Bind<Src: AsRef<Path>> {
|
||||
src: Src,
|
||||
filetype: FileType,
|
||||
}
|
||||
impl<Src: AsRef<Path>> Bind<Src> {
|
||||
pub fn new(src: Src) -> Self {
|
||||
Self {
|
||||
src,
|
||||
filetype: FileType::Directory,
|
||||
}
|
||||
}
|
||||
pub fn with_type(mut self, filetype: FileType) -> Self {
|
||||
self.filetype = filetype;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<SrcDir: AsRef<Path> + Send + Sync> FileSystem for Bind<SrcDir> {
|
||||
impl<Src: AsRef<Path> + Send + Sync> FileSystem for Bind<Src> {
|
||||
async fn source(&self) -> Result<Option<impl AsRef<Path>>, Error> {
|
||||
Ok(Some(&self.src_dir))
|
||||
Ok(Some(&self.src))
|
||||
}
|
||||
fn extra_args(&self) -> impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>> {
|
||||
["--bind"]
|
||||
}
|
||||
async fn pre_mount(&self) -> Result<(), Error> {
|
||||
tokio::fs::create_dir_all(self.src_dir.as_ref()).await?;
|
||||
async fn pre_mount(&self, mountpoint: &Path) -> Result<(), Error> {
|
||||
let from_meta = tokio::fs::metadata(&self.src).await.ok();
|
||||
let to_meta = tokio::fs::metadata(&mountpoint).await.ok();
|
||||
if matches!(self.filetype, FileType::File)
|
||||
|| (matches!(self.filetype, FileType::Infer)
|
||||
&& from_meta.as_ref().map_or(false, |m| m.is_file()))
|
||||
{
|
||||
if to_meta.as_ref().map_or(false, |m| m.is_dir()) {
|
||||
tokio::fs::remove_dir(mountpoint).await?;
|
||||
}
|
||||
if from_meta.is_none() {
|
||||
create_file(self.src.as_ref()).await?.sync_all().await?;
|
||||
}
|
||||
if to_meta.is_none() {
|
||||
create_file(mountpoint).await?.sync_all().await?;
|
||||
}
|
||||
} else {
|
||||
if to_meta.as_ref().map_or(false, |m| m.is_file()) {
|
||||
tokio::fs::remove_file(mountpoint).await?;
|
||||
}
|
||||
if from_meta.is_none() {
|
||||
tokio::fs::create_dir_all(self.src.as_ref()).await?;
|
||||
}
|
||||
if to_meta.is_none() {
|
||||
tokio::fs::create_dir_all(mountpoint).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn source_hash(
|
||||
@@ -33,12 +78,12 @@ impl<SrcDir: AsRef<Path> + Send + Sync> FileSystem for Bind<SrcDir> {
|
||||
let mut sha = Sha256::new();
|
||||
sha.update("Bind");
|
||||
sha.update(
|
||||
tokio::fs::canonicalize(self.src_dir.as_ref())
|
||||
tokio::fs::canonicalize(self.src.as_ref())
|
||||
.await
|
||||
.with_ctx(|_| {
|
||||
(
|
||||
crate::ErrorKind::Filesystem,
|
||||
self.src_dir.as_ref().display().to_string(),
|
||||
self.src.as_ref().display().to_string(),
|
||||
)
|
||||
})?
|
||||
.as_os_str()
|
||||
|
||||
@@ -49,8 +49,7 @@ impl<EncryptedDir: AsRef<Path> + Send + Sync, Key: AsRef<str> + Send + Sync> Fil
|
||||
mountpoint: P,
|
||||
mount_type: super::MountType,
|
||||
) -> Result<(), Error> {
|
||||
self.pre_mount().await?;
|
||||
tokio::fs::create_dir_all(mountpoint.as_ref()).await?;
|
||||
self.pre_mount(mountpoint.as_ref()).await?;
|
||||
Command::new("mount")
|
||||
.args(
|
||||
default_mount_command(self, mountpoint, mount_type)
|
||||
|
||||
@@ -53,16 +53,15 @@ impl<Fs: FileSystem> FileSystem for IdMapped<Fs> {
|
||||
async fn source(&self) -> Result<Option<impl AsRef<Path>>, Error> {
|
||||
self.filesystem.source().await
|
||||
}
|
||||
async fn pre_mount(&self) -> Result<(), Error> {
|
||||
self.filesystem.pre_mount().await
|
||||
async fn pre_mount(&self, mountpoint: &Path) -> Result<(), Error> {
|
||||
self.filesystem.pre_mount(mountpoint).await
|
||||
}
|
||||
async fn mount<P: AsRef<Path> + Send>(
|
||||
&self,
|
||||
mountpoint: P,
|
||||
mount_type: MountType,
|
||||
) -> Result<(), Error> {
|
||||
self.pre_mount().await?;
|
||||
tokio::fs::create_dir_all(mountpoint.as_ref()).await?;
|
||||
self.pre_mount(mountpoint.as_ref()).await?;
|
||||
Command::new("mount.next")
|
||||
.args(
|
||||
default_mount_command(self, mountpoint, mount_type)
|
||||
|
||||
@@ -69,8 +69,7 @@ pub(self) async fn default_mount_impl(
|
||||
mountpoint: impl AsRef<Path> + Send,
|
||||
mount_type: MountType,
|
||||
) -> Result<(), Error> {
|
||||
fs.pre_mount().await?;
|
||||
tokio::fs::create_dir_all(mountpoint.as_ref()).await?;
|
||||
fs.pre_mount(mountpoint.as_ref()).await?;
|
||||
Command::from(default_mount_command(fs, mountpoint, mount_type).await?)
|
||||
.capture(false)
|
||||
.invoke(ErrorKind::Filesystem)
|
||||
@@ -92,8 +91,11 @@ pub trait FileSystem: Send + Sync {
|
||||
fn source(&self) -> impl Future<Output = Result<Option<impl AsRef<Path>>, Error>> + Send {
|
||||
async { Ok(None::<&Path>) }
|
||||
}
|
||||
fn pre_mount(&self) -> impl Future<Output = Result<(), Error>> + Send {
|
||||
async { Ok(()) }
|
||||
fn pre_mount(&self, mountpoint: &Path) -> impl Future<Output = Result<(), Error>> + Send {
|
||||
async move {
|
||||
tokio::fs::create_dir_all(mountpoint).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn mount<P: AsRef<Path> + Send>(
|
||||
&self,
|
||||
|
||||
@@ -41,9 +41,10 @@ impl<
|
||||
Box::new(lazy_format!("workdir={}", self.work.as_ref().display())),
|
||||
]
|
||||
}
|
||||
async fn pre_mount(&self) -> Result<(), Error> {
|
||||
async fn pre_mount(&self, mountpoint: &Path) -> Result<(), Error> {
|
||||
tokio::fs::create_dir_all(self.upper.as_ref()).await?;
|
||||
tokio::fs::create_dir_all(self.work.as_ref()).await?;
|
||||
tokio::fs::create_dir_all(mountpoint).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn source_hash(
|
||||
|
||||
@@ -10,10 +10,10 @@ use models::{FromStrParser, HealthCheckId, PackageId, ReplayId, VersionString, V
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::db::model::package::{
|
||||
TaskEntry, CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind,
|
||||
ManifestPreference,
|
||||
CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference,
|
||||
TaskEntry,
|
||||
};
|
||||
use crate::disk::mount::filesystem::bind::Bind;
|
||||
use crate::disk::mount::filesystem::bind::{Bind, FileType};
|
||||
use crate::disk::mount::filesystem::idmapped::IdMapped;
|
||||
use crate::disk::mount::filesystem::{FileSystem, MountType};
|
||||
use crate::disk::mount::util::{is_mountpoint, unmount};
|
||||
@@ -23,14 +23,6 @@ use crate::util::Invoke;
|
||||
use crate::volume::data_dir;
|
||||
use crate::DATA_DIR;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FileType {
|
||||
File,
|
||||
Directory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -39,8 +31,7 @@ pub struct MountTarget {
|
||||
volume_id: VolumeId,
|
||||
subpath: Option<PathBuf>,
|
||||
readonly: bool,
|
||||
#[ts(optional)]
|
||||
filetype: Option<FileType>,
|
||||
filetype: FileType,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
@@ -67,7 +58,6 @@ pub async fn mount(
|
||||
let subpath = subpath.unwrap_or_default();
|
||||
let subpath = subpath.strip_prefix("/").unwrap_or(&subpath);
|
||||
let source = data_dir(DATA_DIR, &package_id, &volume_id).join(subpath);
|
||||
let from_meta = tokio::fs::metadata(&source).await.ok();
|
||||
let location = location.strip_prefix("/").unwrap_or(&location);
|
||||
let mountpoint = context
|
||||
.seed
|
||||
@@ -77,39 +67,7 @@ pub async fn mount(
|
||||
.or_not_found("lxc container")?
|
||||
.rootfs_dir()
|
||||
.join(location);
|
||||
let to_meta = tokio::fs::metadata(&mountpoint).await.ok();
|
||||
|
||||
if matches!(filetype, Some(FileType::File))
|
||||
|| (filetype.is_none() && from_meta.as_ref().map_or(false, |m| m.is_file()))
|
||||
{
|
||||
if to_meta.as_ref().map_or(false, |m| m.is_dir()) {
|
||||
tokio::fs::remove_dir(&mountpoint).await?;
|
||||
}
|
||||
if from_meta.is_none() {
|
||||
if let Some(parent) = source.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(&source, "").await?;
|
||||
}
|
||||
if to_meta.is_none() {
|
||||
if let Some(parent) = mountpoint.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(&mountpoint, "").await?;
|
||||
}
|
||||
} else {
|
||||
if to_meta.as_ref().map_or(false, |m| m.is_file()) {
|
||||
tokio::fs::remove_file(&mountpoint).await?;
|
||||
}
|
||||
if from_meta.is_none() {
|
||||
tokio::fs::create_dir_all(&source).await?;
|
||||
}
|
||||
if to_meta.is_none() {
|
||||
tokio::fs::create_dir_all(&mountpoint).await?;
|
||||
}
|
||||
}
|
||||
|
||||
tokio::fs::create_dir_all(&mountpoint).await?;
|
||||
if is_mountpoint(&mountpoint).await? {
|
||||
unmount(&mountpoint, true).await?;
|
||||
}
|
||||
@@ -118,7 +76,7 @@ pub async fn mount(
|
||||
.arg(&mountpoint)
|
||||
.invoke(crate::ErrorKind::Filesystem)
|
||||
.await?;
|
||||
IdMapped::new(Bind::new(source), 0, 100000, 65536)
|
||||
IdMapped::new(Bind::new(source).with_type(filetype), 0, 100000, 65536)
|
||||
.mount(
|
||||
mountpoint,
|
||||
if readonly {
|
||||
|
||||
@@ -211,27 +211,12 @@ impl VersionT for Version {
|
||||
}
|
||||
fn up(self, db: &mut Value, (account, ssh_keys, cifs): Self::PreUpRes) -> Result<(), Error> {
|
||||
let wifi = json!({
|
||||
"infterface": db["server-info"]["wifi"]["interface"],
|
||||
"interface": db["server-info"]["wifi"]["interface"],
|
||||
"ssids": db["server-info"]["wifi"]["ssids"],
|
||||
"selected": db["server-info"]["wifi"]["selected"],
|
||||
"last_region": db["server-info"]["wifi"]["last-region"],
|
||||
"lastRegion": db["server-info"]["wifi"]["last-region"],
|
||||
});
|
||||
|
||||
let ip_info = {
|
||||
let mut ip_info = json!({});
|
||||
let empty = Default::default();
|
||||
for (k, v) in db["server-info"]["ip-info"].as_object().unwrap_or(&empty) {
|
||||
let k: &str = k.as_ref();
|
||||
ip_info[k] = json!({
|
||||
"ipv4Range": v["ipv4-range"],
|
||||
"ipv6Range": v["ipv6-range"],
|
||||
"ipv4": v["ipv4"],
|
||||
"ipv6": v["ipv6"],
|
||||
});
|
||||
}
|
||||
ip_info
|
||||
};
|
||||
|
||||
let status_info = json!({
|
||||
"backupProgress": db["server-info"]["status-info"]["backup-progress"],
|
||||
"updated": db["server-info"]["status-info"]["updated"],
|
||||
@@ -259,7 +244,7 @@ impl VersionT for Version {
|
||||
.replace("https://", "")
|
||||
.replace("http://", "")
|
||||
.replace(".onion/", ""));
|
||||
server_info["ipInfo"] = ip_info;
|
||||
server_info["networkInterfaces"] = json!({});
|
||||
server_info["statusInfo"] = status_info;
|
||||
server_info["wifi"] = wifi;
|
||||
server_info["unreadNotificationCount"] =
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ExtendedVersion, VersionRange } from "./exver"
|
||||
import {
|
||||
ActionId,
|
||||
ActionInput,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
ServiceInterface,
|
||||
CreateTaskParams,
|
||||
MainStatus,
|
||||
MountParams,
|
||||
} from "./osBindings"
|
||||
import {
|
||||
PackageId,
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
SmtpValue,
|
||||
ActionResult,
|
||||
} from "./types"
|
||||
import { UrlString } from "./util/getServiceInterface"
|
||||
|
||||
/** Used to reach out from the pure js runtime */
|
||||
|
||||
@@ -80,15 +79,7 @@ export type Effects = {
|
||||
packageIds?: PackageId[]
|
||||
}): Promise<CheckDependenciesResult[]>
|
||||
/** mount a volume of a dependency */
|
||||
mount(options: {
|
||||
location: string
|
||||
target: {
|
||||
packageId: string
|
||||
volumeId: string
|
||||
subpath: string | null
|
||||
readonly: boolean
|
||||
}
|
||||
}): Promise<string>
|
||||
mount(options: MountParams): Promise<string>
|
||||
/** Returns a list of the ids of all installed packages */
|
||||
getInstalledPackages(): Promise<string[]>
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type FileType = "file" | "directory"
|
||||
export type FileType = "file" | "directory" | "infer"
|
||||
|
||||
@@ -8,5 +8,5 @@ export type MountTarget = {
|
||||
volumeId: VolumeId
|
||||
subpath: string | null
|
||||
readonly: boolean
|
||||
filetype?: FileType
|
||||
filetype: FileType
|
||||
}
|
||||
|
||||
@@ -412,7 +412,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
id: string
|
||||
/** The human readable description. */
|
||||
description: string
|
||||
/** Affects how the interface appears to the user. One of: 'ui', 'api', 'p2p'. If 'ui', the user will see a "Launch UI" button */
|
||||
/** Affects how the interface appears to the user. One of: 'ui', 'api', 'p2p'. If 'ui', the user will see an option to open the UI in a new tab */
|
||||
type: ServiceInterfaceType
|
||||
/** (optional) prepends the provided username to all URLs. */
|
||||
username: null | string
|
||||
|
||||
@@ -187,10 +187,10 @@ export class FileHelper<A> {
|
||||
/**
|
||||
* Reads the file from disk and converts it to structured data.
|
||||
*/
|
||||
private async readOnce(): Promise<A | null> {
|
||||
private async readOnce<B>(map: (value: A) => B): Promise<B | null> {
|
||||
const data = await this.readFile()
|
||||
if (!data) return null
|
||||
return this.validate(data)
|
||||
return map(this.validate(data))
|
||||
}
|
||||
|
||||
private async readConst<B>(
|
||||
@@ -224,8 +224,7 @@ export class FileHelper<A> {
|
||||
persistent: false,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
const newResFull = await this.readOnce()
|
||||
const newRes = newResFull ? map(newResFull) : null
|
||||
const newRes = await this.readOnce(map)
|
||||
const listen = Promise.resolve()
|
||||
.then(async () => {
|
||||
for await (const _ of watch) {
|
||||
@@ -284,7 +283,7 @@ export class FileHelper<A> {
|
||||
map = map ?? ((a: A) => a)
|
||||
eq = eq ?? ((left: any, right: any) => !partialDiff(left, right))
|
||||
return {
|
||||
once: () => this.readOnce(),
|
||||
once: () => this.readOnce(map),
|
||||
const: (effects: T.Effects) => this.readConst(effects, map, eq),
|
||||
watch: (effects: T.Effects) => this.readWatch(effects, map, eq),
|
||||
onChange: (
|
||||
|
||||
4
sdk/package/package-lock.json
generated
4
sdk/package/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.23",
|
||||
"version": "0.4.0-beta.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.23",
|
||||
"version": "0.4.0-beta.24",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^3.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.4.0-beta.23",
|
||||
"version": "0.4.0-beta.24",
|
||||
"description": "Software development kit to facilitate packaging services for StartOS",
|
||||
"main": "./package/lib/index.js",
|
||||
"types": "./package/lib/index.d.ts",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"https://registry.start9.com/": "Start9 Registry",
|
||||
"https://community-registry.start9.com/": "Community Registry"
|
||||
},
|
||||
"startosRegisrty": "https://registry.start9.com/",
|
||||
"startosRegistry": "https://registry.start9.com/",
|
||||
"snakeHighScore": 0,
|
||||
"ackInstructions": {}
|
||||
}
|
||||
|
||||
@@ -61,3 +61,8 @@ main {
|
||||
margin-left: -100%;
|
||||
}
|
||||
}
|
||||
|
||||
[tuiCell]:not(:last-of-type) {
|
||||
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,12 @@ export class AppComponent {
|
||||
this.dialogs
|
||||
.open(
|
||||
'Please wait for StartOS to restart, then refresh this page',
|
||||
{ label: 'Rebooting', size: 's' },
|
||||
{
|
||||
label: 'Rebooting',
|
||||
size: 's',
|
||||
closeable: false,
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
Past Release Notes
|
||||
</button>
|
||||
<h2 class="additional-detail-title" [style.margin-top.rem]="2">About</h2>
|
||||
<p>{{ pkg.description.long }}</p>
|
||||
<p [innerHTML]="pkg.description.long"></p>
|
||||
<a
|
||||
*ngIf="pkg.marketingSite as url"
|
||||
tuiButton
|
||||
|
||||
@@ -20,8 +20,8 @@ import { MarketplacePkgBase } from '../../../types'
|
||||
</span>
|
||||
<p>
|
||||
<ng-container [ngSwitch]="dep.value.optional">
|
||||
<span *ngSwitchCase="true">(required)</span>
|
||||
<span *ngSwitchCase="false">(optional)</span>
|
||||
<span *ngSwitchCase="true">(optional)</span>
|
||||
<span *ngSwitchCase="false">(required)</span>
|
||||
</ng-container>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from './services/state.service'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -11,9 +13,15 @@ export class AppComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly document = inject(DOCUMENT)
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.stateService.kiosk = ['localhost', '127.0.0.1'].includes(
|
||||
this.document.location.hostname,
|
||||
)
|
||||
|
||||
const inProgress = await this.api.getStatus()
|
||||
|
||||
let route = 'home'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PreloadAllModules, RouterModule } from '@angular/router'
|
||||
import {
|
||||
provideSetupLogsService,
|
||||
RELATIVE_URL,
|
||||
VERSION,
|
||||
WorkspaceConfig,
|
||||
} from '@start9labs/shared'
|
||||
import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core'
|
||||
@@ -20,6 +21,8 @@ const {
|
||||
ui: { api },
|
||||
} = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
const version = require('../../../../package.json').version
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
@@ -43,6 +46,10 @@ const {
|
||||
provide: RELATIVE_URL,
|
||||
useValue: `/${api.url}/${api.version}`,
|
||||
},
|
||||
{
|
||||
provide: VERSION,
|
||||
useValue: version,
|
||||
},
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ const FADE_FACTOR = 0.07
|
||||
standalone: true,
|
||||
selector: 'canvas[matrix]',
|
||||
template: 'Your browser does not support the canvas element.',
|
||||
styles: ':host { position: fixed; }',
|
||||
styles: ':host { position: fixed; top: 0 }',
|
||||
})
|
||||
export class MatrixComponent implements OnInit {
|
||||
private readonly ngZone = inject(NgZone)
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { TuiButton, TuiDialogContext, TuiError } from '@taiga-ui/core'
|
||||
import { TuiInputPasswordModule } from '@taiga-ui/legacy'
|
||||
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiError,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiFieldErrorPipe,
|
||||
TuiPassword,
|
||||
tuiValidationErrorsProvider,
|
||||
} from '@taiga-ui/kit'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
|
||||
interface DialogData {
|
||||
@@ -21,18 +39,38 @@ interface DialogData {
|
||||
Enter the password that was used to encrypt this drive.
|
||||
}
|
||||
|
||||
<form [style.margin-top.rem]="1" (ngSubmit)="submit()">
|
||||
<tui-input-password [formControl]="password">
|
||||
Enter Password
|
||||
<input tuiTextfieldLegacy maxlength="64" />
|
||||
</tui-input-password>
|
||||
<tui-error [error]="passwordError"></tui-error>
|
||||
<form [formGroup]="form" [style.margin-top.rem]="1" (ngSubmit)="submit()">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Enter Password</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
tuiAutoFocus
|
||||
maxlength="64"
|
||||
formControlName="password"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="password"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
@if (storageDrive) {
|
||||
<tui-input-password [style.margin-top.rem]="1" [formControl]="confirm">
|
||||
Retype Password
|
||||
<input tuiTextfieldLegacy maxlength="64" />
|
||||
</tui-input-password>
|
||||
<tui-error [error]="confirmError"></tui-error>
|
||||
<tui-textfield [style.margin-top.rem]="1">
|
||||
<label tuiLabel>Retype Password</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
maxlength="64"
|
||||
formControlName="confirm"
|
||||
[tuiValidator]="form.controls.password.value | tuiMapper: validator"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="confirm"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
}
|
||||
<footer>
|
||||
<button
|
||||
@@ -43,22 +81,38 @@ interface DialogData {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!password.value || !!confirmError || !!passwordError"
|
||||
>
|
||||
<button tuiButton [disabled]="form.invalid">
|
||||
{{ storageDrive ? 'Finish' : 'Unlock' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
styles: ['footer { display: flex; gap: 1rem; margin-top: 1rem }'],
|
||||
styles: `
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
TuiButton,
|
||||
TuiInputPasswordModule,
|
||||
TuiError,
|
||||
TuiAutoFocus,
|
||||
TuiFieldErrorPipe,
|
||||
TuiTextfield,
|
||||
TuiPassword,
|
||||
TuiValidator,
|
||||
TuiIcon,
|
||||
TuiMapperPipe,
|
||||
],
|
||||
providers: [
|
||||
tuiValidationErrorsProvider({
|
||||
required: 'Required',
|
||||
minlength: 'Must be 12 characters or greater',
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class PasswordComponent {
|
||||
@@ -67,31 +121,29 @@ export class PasswordComponent {
|
||||
injectContext<TuiDialogContext<string, DialogData>>()
|
||||
|
||||
readonly storageDrive = this.context.data.storageDrive
|
||||
readonly password = new FormControl('', { nonNullable: true })
|
||||
readonly confirm = new FormControl('', { nonNullable: true })
|
||||
readonly form = new FormGroup({
|
||||
password: new FormControl('', [
|
||||
Validators.required,
|
||||
Validators.minLength(12),
|
||||
]),
|
||||
confirm: new FormControl('', this.storageDrive ? Validators.required : []),
|
||||
})
|
||||
|
||||
get passwordError(): string | null {
|
||||
return this.password.touched && this.password.value.length < 12
|
||||
? 'Must be 12 characters or greater'
|
||||
: null
|
||||
}
|
||||
|
||||
get confirmError(): string | null {
|
||||
return this.confirm.touched && this.password.value !== this.confirm.value
|
||||
? 'Passwords do not match'
|
||||
: null
|
||||
}
|
||||
readonly validator = (value: any) => (control: AbstractControl) =>
|
||||
value === control.value ? null : { match: 'Passwords do not match' }
|
||||
|
||||
submit() {
|
||||
const password = this.form.controls.password.value || ''
|
||||
|
||||
if (this.storageDrive) {
|
||||
this.context.completeWith(this.password.value)
|
||||
this.context.completeWith(password)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
argon2.verify(this.context.data.passwordHash || '', this.password.value)
|
||||
this.context.completeWith(this.password.value)
|
||||
argon2.verify(this.context.data.passwordHash || '', password)
|
||||
this.context.completeWith(password)
|
||||
} catch (e) {
|
||||
this.errorService.handleError('Incorrect password provided')
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { StateService } from 'src/app/services/state.service'
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<section tuiCardLarge>
|
||||
<section tuiCardLarge="compact">
|
||||
<header>Use existing drive</header>
|
||||
<div>Select the physical drive containing your StartOS data</div>
|
||||
|
||||
@@ -31,9 +31,11 @@ import { StateService } from 'src/app/services/state.service'
|
||||
valid StartOS data drive (not a backup) and is firmly connected, then
|
||||
refresh the page.
|
||||
}
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { StateService } from 'src/app/services/state.service'
|
||||
template: `
|
||||
<img class="logo" src="assets/img/icon.png" alt="Start9" />
|
||||
@if (!loading) {
|
||||
<section tuiCardLarge>
|
||||
<section tuiCardLarge="compact">
|
||||
<header [style.padding-top.rem]="1.25">
|
||||
@if (recover) {
|
||||
<button
|
||||
@@ -30,7 +30,7 @@ import { StateService } from 'src/app/services/state.service'
|
||||
</header>
|
||||
<div class="pages">
|
||||
<div class="options" [class.options_recover]="recover">
|
||||
<a tuiCell [routerLink]="error || recover ? null : '/storage'">
|
||||
<button tuiCell [routerLink]="error || recover ? null : '/storage'">
|
||||
<tui-icon icon="@tui.plus" />
|
||||
<span tuiTitle>
|
||||
<span class="g-positive">Start Fresh</span>
|
||||
@@ -38,7 +38,7 @@ import { StateService } from 'src/app/services/state.service'
|
||||
Get started with a brand new Start9 server
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
<button
|
||||
tuiCell
|
||||
[disabled]="error || recover"
|
||||
|
||||
@@ -17,7 +17,7 @@ import { StateService } from 'src/app/services/state.service'
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<section tuiCardLarge>
|
||||
<section tuiCardLarge="compact">
|
||||
<header>Restore from Backup</header>
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
@@ -26,7 +26,7 @@ import { StateService } from 'src/app/services/state.service'
|
||||
Restore StartOS data from a folder on another computer that is connected
|
||||
to the same network as your server.
|
||||
|
||||
<button tuiCell (click)="onCifs()">
|
||||
<button tuiCell [style.box-shadow]="'none'" (click)="onCifs()">
|
||||
<tui-icon icon="@tui.folder" />
|
||||
<span tuiTitle>Open</span>
|
||||
</button>
|
||||
@@ -49,10 +49,11 @@ import { StateService } from 'src/app/services/state.service'
|
||||
(password)="select($event, server)"
|
||||
></button>
|
||||
}
|
||||
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { StateService } from 'src/app/services/state.service'
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<section tuiCardLarge>
|
||||
<section tuiCardLarge="compact">
|
||||
@if (loading || drives.length) {
|
||||
<header>Select storage drive</header>
|
||||
This is the drive where your StartOS data will be stored.
|
||||
@@ -39,10 +39,11 @@ import { StateService } from 'src/app/services/state.service'
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
imports: [TuiCardLarge, TuiLoader, TuiCell, TuiButton, DriveComponent],
|
||||
|
||||
@@ -18,15 +18,13 @@ import { StateService } from 'src/app/services/state.service'
|
||||
standalone: true,
|
||||
template: `
|
||||
<canvas matrix></canvas>
|
||||
@if (isKiosk) {
|
||||
@if (stateService.kiosk) {
|
||||
<section tuiCardLarge>
|
||||
<h1 class="heading">
|
||||
<tui-icon icon="@tui.check-square" class="g-positive" />
|
||||
Setup Complete!
|
||||
</h1>
|
||||
<button tuiButton (click)="exitKiosk()" iconEnd="@tui.log-in">
|
||||
Continue to Login
|
||||
</button>
|
||||
<button tuiButton (click)="exitKiosk()">Continue to Login</button>
|
||||
</section>
|
||||
} @else if (lanAddress) {
|
||||
<section tuiCardLarge>
|
||||
@@ -111,16 +109,12 @@ import { StateService } from 'src/app/services/state.service'
|
||||
export default class SuccessPage implements AfterViewInit {
|
||||
@ViewChild(DocumentationComponent, { read: ElementRef })
|
||||
private readonly documentation?: ElementRef<HTMLElement>
|
||||
|
||||
private readonly document = inject(DOCUMENT)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly downloadHtml = inject(DownloadHTMLService)
|
||||
|
||||
readonly stateService = inject(StateService)
|
||||
readonly isKiosk = ['localhost', '127.0.0.1'].includes(
|
||||
this.document.location.hostname,
|
||||
)
|
||||
|
||||
torAddresses?: string[]
|
||||
lanAddress?: string
|
||||
@@ -157,7 +151,7 @@ export default class SuccessPage implements AfterViewInit {
|
||||
private async complete() {
|
||||
try {
|
||||
const ret = await this.api.complete()
|
||||
if (!this.isKiosk) {
|
||||
if (!this.stateService.kiosk) {
|
||||
this.torAddresses = ret.torAddresses.map(a =>
|
||||
a.replace(/^https:/, 'http:'),
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ import { StateService } from 'src/app/services/state.service'
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<section tuiCardLarge>
|
||||
<section tuiCardLarge="compact">
|
||||
<header>Transfer</header>
|
||||
Select the physical drive containing your StartOS data
|
||||
@if (loading) {
|
||||
@@ -30,9 +30,11 @@ import { StateService } from 'src/app/services/state.service'
|
||||
@for (drive of drives; track drive) {
|
||||
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
|
||||
}
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
imports: [TuiCardLarge, TuiCell, TuiButton, TuiLoader, DriveComponent],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { T } from '@start9labs/start-sdk'
|
||||
export class StateService {
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
kiosk?: boolean
|
||||
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
|
||||
recoverySource?: T.RecoverySource<string>
|
||||
|
||||
@@ -15,6 +16,7 @@ export class StateService {
|
||||
await this.api.attach({
|
||||
guid,
|
||||
startOsPassword: await this.api.encrypt(password),
|
||||
kiosk: this.kiosk,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,6 +35,7 @@ export class StateService {
|
||||
password: await this.api.encrypt(this.recoverySource.password),
|
||||
}
|
||||
: null,
|
||||
kiosk: this.kiosk,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ router-outlet + * {
|
||||
|
||||
[tuiCardLarge] {
|
||||
width: 100%;
|
||||
background: var(--tui-background-base-alt);
|
||||
background: var(--tui-background-elevation-2);
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
@@ -67,3 +67,11 @@ h2 {
|
||||
.g-info {
|
||||
color: var(--tui-status-info);
|
||||
}
|
||||
|
||||
[tuiCardLarge] footer button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[tuiCell]:not(:last-of-type) {
|
||||
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
|
||||
}
|
||||
|
||||
BIN
web/projects/shared/assets/img/background_marketplace.jpg
Normal file
BIN
web/projects/shared/assets/img/background_marketplace.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 694 KiB |
@@ -12,7 +12,7 @@ import { i18nPipe } from '../i18n/i18n.pipe'
|
||||
<h1 [style.font-size.rem]="2" [style.margin-bottom.rem]="2">
|
||||
{{ 'Setting up your server' | i18n }}
|
||||
</h1>
|
||||
<div *ngIf="progress.total">
|
||||
<div>
|
||||
{{ 'Progress' | i18n }}: {{ (progress.total * 100).toFixed(0) }}%
|
||||
</div>
|
||||
<progress
|
||||
@@ -21,7 +21,7 @@ import { i18nPipe } from '../i18n/i18n.pipe'
|
||||
[style.margin]="'1rem auto'"
|
||||
[attr.value]="progress.total"
|
||||
></progress>
|
||||
<p [innerHTML]="progress.message"></p>
|
||||
<p [innerHTML]="progress.message || 'Finished'"></p>
|
||||
</section>
|
||||
<logs-window />
|
||||
`,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
&:hover {
|
||||
text-indent: var(--indent, 0);
|
||||
text-overflow: clip;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
input,
|
||||
} from '@angular/core'
|
||||
|
||||
const HOST = 'https://staging.docs.start9.com'
|
||||
export const VERSION = new InjectionToken<string>('VERSION')
|
||||
|
||||
@Directive({
|
||||
@@ -26,6 +25,6 @@ export class DocsLinkDirective {
|
||||
protected readonly url = computed(() => {
|
||||
const path = this.href()
|
||||
const relative = path.startsWith('/') ? path : `/${path}`
|
||||
return `${HOST}${relative}?os=${this.version}`
|
||||
return `https://docs.start9.com${relative}?os=${this.version}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export default {
|
||||
57: 'Herunterfahren wird eingeleitet',
|
||||
58: 'Hinzufügen',
|
||||
59: 'Ok',
|
||||
60: 'Möchten Sie diesen Eintrag wirklich löschen?',
|
||||
60: 'französisch',
|
||||
61: 'Dieser Wert kann nach dem Festlegen nicht geändert werden',
|
||||
62: 'Fortfahren',
|
||||
63: 'Klicken oder Datei hierher ziehen',
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
82: 'Metriken',
|
||||
83: 'Protokolle',
|
||||
84: 'Benachrichtigungen',
|
||||
85: 'UI starten',
|
||||
85: 'Hartes Deinstallieren',
|
||||
86: 'QR-Code anzeigen',
|
||||
87: 'URL kopieren',
|
||||
88: 'Aktionen',
|
||||
@@ -230,9 +230,9 @@ export default {
|
||||
227: 'Unbekannter Fehler',
|
||||
228: 'Fehler',
|
||||
229: '"Container neu bauen" ist eine harmlose Aktion, die nur wenige Sekunden dauert. Sie wird dieses Problem wahrscheinlich beheben.',
|
||||
230: '"Dienst deinstallieren" ist eine gefährliche Aktion, die den Dienst aus StartOS entfernt und alle zugehörigen Daten dauerhaft löscht.',
|
||||
230: '"Hartes Deinstallieren" ist eine gefährliche Aktion, die den Dienst aus StartOS entfernt und alle zugehörigen Daten dauerhaft löscht.',
|
||||
231: 'Container neu bauen',
|
||||
232: 'Dienst deinstallieren',
|
||||
232: 'Weiches Deinstallieren',
|
||||
233: 'Vollständige Nachricht anzeigen',
|
||||
234: 'Dienstfehler',
|
||||
235: 'Warte auf Ergebnis',
|
||||
@@ -247,7 +247,6 @@ export default {
|
||||
244: 'Hosting',
|
||||
245: 'Installation läuft',
|
||||
246: 'Siehe unten',
|
||||
247: 'Steuerelemente',
|
||||
248: 'Keine Dienste installiert',
|
||||
249: 'Läuft',
|
||||
250: 'Gestoppt',
|
||||
@@ -414,12 +413,12 @@ export default {
|
||||
411: 'Weitere Netzwerke',
|
||||
412: 'WiFi ist deaktiviert',
|
||||
413: 'Keine drahtlose Schnittstelle erkannt',
|
||||
414: 'WiFi wird aktiviert',
|
||||
415: 'WiFi wird deaktiviert',
|
||||
414: 'wird aktiviert',
|
||||
415: 'wird deaktiviert',
|
||||
416: 'Verbindung wird hergestellt. Dies kann einen Moment dauern',
|
||||
417: 'Erneut versuchen',
|
||||
418: 'Mehr anzeigen',
|
||||
419: 'Versionshinweise',
|
||||
419: 'Details anzeigen',
|
||||
420: 'Eintrag anzeigen',
|
||||
421: 'Dienste, die von folgendem abhängen:',
|
||||
422: 'werden nicht mehr ordnungsgemäß funktionieren und könnten abstürzen.',
|
||||
@@ -503,5 +502,20 @@ export default {
|
||||
500: 'Marktplatz anzeigen',
|
||||
501: 'Willkommen bei',
|
||||
502: 'souveränes computing',
|
||||
503: 'französisch',
|
||||
503: 'Passen Sie den Namen an, der in Ihrem Browser-Tab erscheint',
|
||||
504: 'Verwalten',
|
||||
505: 'Möchten Sie diese Adresse wirklich löschen?',
|
||||
506: '"Weiches Deinstallieren" entfernt den Dienst aus StartOS, behält jedoch die Daten bei.',
|
||||
507: 'Keine gespeicherten Anbieter',
|
||||
508: 'Kiosk-Modus',
|
||||
509: 'Aktiviert',
|
||||
510: 'Deaktiviere den Kiosk-Modus, es sei denn, du musst einen Monitor anschließen',
|
||||
511: 'Aktiviere den Kiosk-Modus, wenn du einen Monitor anschließen musst',
|
||||
512: 'Der Kiosk-Modus ist auf diesem Gerät nicht verfügbar',
|
||||
513: 'Aktivieren',
|
||||
514: 'Deaktivieren',
|
||||
515: 'Du verwendest derzeit einen Kiosk. Wenn du den Kiosk-Modus deaktivierst, wird die Verbindung zum Kiosk getrennt.',
|
||||
516: 'Empfohlen',
|
||||
517: 'Möchten Sie diese Aufgabe wirklich verwerfen?',
|
||||
518: 'Verwerfen',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ENGLISH = {
|
||||
'Beginning restart': 42,
|
||||
'You are on the latest version of StartOS.': 43,
|
||||
'Up to date!': 44,
|
||||
'Release Notes': 45,
|
||||
'Release notes': 45,
|
||||
'Begin Update': 46,
|
||||
'Beginning update': 47,
|
||||
'You are currently connected over Tor. If you reset the Tor daemon, you will lose connectivity until it comes back online.': 48,
|
||||
@@ -59,7 +59,7 @@ export const ENGLISH = {
|
||||
'Beginning shutdown': 57,
|
||||
'Add': 58,
|
||||
'Ok': 59,
|
||||
'Are you sure you want to delete this entry?': 60,
|
||||
'french': 60,
|
||||
'This value cannot be changed once set': 61,
|
||||
'Continue': 62,
|
||||
'Click or drop file here': 63,
|
||||
@@ -84,7 +84,7 @@ export const ENGLISH = {
|
||||
'Metrics': 82, // system info such as CPU, RAM, and storage usage
|
||||
'Logs': 83, // as in, application logs
|
||||
'Notifications': 84,
|
||||
'Launch UI': 85,
|
||||
'Hard uninstall': 85, // as in, hard reset or hard reboot, except for uninstalling
|
||||
'Show QR': 86,
|
||||
'Copy URL': 87,
|
||||
'Actions': 88, // as in, actions available to the user
|
||||
@@ -229,9 +229,9 @@ export const ENGLISH = {
|
||||
'Unknown error': 227,
|
||||
'Error': 228,
|
||||
'"Rebuild container" is a harmless action that and only takes a few seconds to complete. It will likely resolve this issue.': 229,
|
||||
'"Uninstall service" is a dangerous action that will remove the service from StartOS and wipe all its data.': 230,
|
||||
'"Hard uninstall" is a dangerous action that will remove the service from StartOS and wipe all its data.': 230,
|
||||
'Rebuild container': 231,
|
||||
'Uninstall service': 232,
|
||||
'Soft uninstall': 232, // as in, uninstall the service but preserve its data
|
||||
'View full message': 233,
|
||||
'Service error': 234,
|
||||
'Awaiting result': 235,
|
||||
@@ -246,7 +246,6 @@ export const ENGLISH = {
|
||||
'Hosting': 244,
|
||||
'Installing': 245,
|
||||
'See below': 246,
|
||||
'Controls': 247,
|
||||
'No services installed': 248,
|
||||
'Running': 249,
|
||||
'Stopped': 250,
|
||||
@@ -413,12 +412,12 @@ export const ENGLISH = {
|
||||
'Other Networks': 411,
|
||||
'WiFi is disabled': 412,
|
||||
'No wireless interface detected': 413,
|
||||
'Enabling WiFi': 414,
|
||||
'Disabling WiFi': 415,
|
||||
'Enabling': 414,
|
||||
'Disabling': 415,
|
||||
'Connecting. This could take a while': 416,
|
||||
'Retry': 417,
|
||||
'Show more': 418,
|
||||
'Release notes': 419,
|
||||
'View details': 419,
|
||||
'View listing': 420,
|
||||
'Services that depend on': 421,
|
||||
'will no longer work properly and may crash.': 422,
|
||||
@@ -502,5 +501,20 @@ export const ENGLISH = {
|
||||
'View Marketplace': 500,
|
||||
'Welcome to': 501,
|
||||
'sovereign computing': 502,
|
||||
'french': 503,
|
||||
'Customize the name appearing in your browser tab': 503,
|
||||
'Manage': 504, // as in, administer
|
||||
'Are you sure you want to delete this address?': 505, // this address referes to a domain or URL
|
||||
'"Soft uninstall" will remove the service from StartOS but preserve its data.': 506,
|
||||
'No saved providers': 507,
|
||||
'Kiosk Mode': 508, // an OS mode that permits attaching a monitor to the computer
|
||||
'Enabled': 509,
|
||||
'Disable Kiosk Mode unless you need to attach a monitor': 510,
|
||||
'Enable Kiosk Mode if you need to attach a monitor': 511,
|
||||
'Kiosk Mode is unavailable on this device': 512,
|
||||
'Enable': 513,
|
||||
'Disable': 514,
|
||||
'You are currently using a kiosk. Disabling Kiosk Mode will result in the kiosk disconnecting.': 515,
|
||||
'Recommended': 516, // as in, we recommend this
|
||||
'Are you sure you want to dismiss this task?': 517,
|
||||
'Dismiss': 518, // as in, dismiss or delete a task
|
||||
} as const
|
||||
|
||||
@@ -45,7 +45,7 @@ export default {
|
||||
42: 'Iniciando reinicio',
|
||||
43: 'Estás usando la última versión de StartOS.',
|
||||
44: '¡Actualizado!',
|
||||
45: 'Notas de la versión',
|
||||
45: 'notas de la versión',
|
||||
46: 'Iniciar actualización',
|
||||
47: 'Iniciando actualización',
|
||||
48: 'Actualmente estás conectado a través de Tor. Si restableces el servicio Tor, perderás la conexión hasta que vuelva a estar en línea.',
|
||||
@@ -60,7 +60,7 @@ export default {
|
||||
57: 'Iniciando apagado',
|
||||
58: 'Agregar',
|
||||
59: 'Ok',
|
||||
60: '¿Estás seguro de que deseas eliminar esta entrada?',
|
||||
60: 'francés',
|
||||
61: 'Este valor no se puede cambiar una vez establecido',
|
||||
62: 'Continuar',
|
||||
63: 'Haz clic o suelta el archivo aquí',
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
82: 'Métricas',
|
||||
83: 'Registros',
|
||||
84: 'Notificaciones',
|
||||
85: 'Abrir interfaz',
|
||||
85: 'Desinstalación forzada',
|
||||
86: 'Mostrar QR',
|
||||
87: 'Copiar URL',
|
||||
88: 'Acciones',
|
||||
@@ -230,9 +230,9 @@ export default {
|
||||
227: 'Error desconocido',
|
||||
228: 'Error',
|
||||
229: '"Reconstruir contenedor" es una acción inofensiva que solo toma unos segundos. Probablemente resolverá este problema.',
|
||||
230: '"Desinstalar servicio" es una acción peligrosa que eliminará el servicio de StartOS y borrará todos sus datos.',
|
||||
230: '"Desinstalación forzada" es una acción peligrosa que eliminará el servicio de StartOS y borrará todos sus datos.',
|
||||
231: 'Reconstruir contenedor',
|
||||
232: 'Desinstalar servicio',
|
||||
232: 'Desinstalación suave',
|
||||
233: 'Ver mensaje completo',
|
||||
234: 'Error del servicio',
|
||||
235: 'Esperando resultado',
|
||||
@@ -247,7 +247,6 @@ export default {
|
||||
244: 'Alojamiento',
|
||||
245: 'Instalando',
|
||||
246: 'Ver abajo',
|
||||
247: 'Controles',
|
||||
248: 'No hay servicios instalados',
|
||||
249: 'En ejecución',
|
||||
250: 'Detenido',
|
||||
@@ -414,12 +413,12 @@ export default {
|
||||
411: 'Otras redes',
|
||||
412: 'WiFi está deshabilitado',
|
||||
413: 'No se detectó interfaz inalámbrica',
|
||||
414: 'Habilitando WiFi',
|
||||
415: 'Deshabilitando WiFi',
|
||||
414: 'Habilitando',
|
||||
415: 'Deshabilitando',
|
||||
416: 'Conectando. Esto podría tardar un poco',
|
||||
417: 'Reintentar',
|
||||
418: 'Mostrar más',
|
||||
419: 'Notas de la versión',
|
||||
419: 'Ver detalles',
|
||||
420: 'Ver listado',
|
||||
421: 'Servicios que dependen de',
|
||||
422: 'ya no funcionarán correctamente y podrían fallar.',
|
||||
@@ -503,5 +502,20 @@ export default {
|
||||
500: 'Ver Marketplace',
|
||||
501: 'Bienvenido a',
|
||||
502: 'computación soberana',
|
||||
503: 'francés',
|
||||
503: 'Personaliza el nombre que aparece en la pestaña de tu navegador',
|
||||
504: 'Administrar',
|
||||
505: '¿Estás seguro de que deseas eliminar esta dirección?',
|
||||
506: '"Desinstalación suave" eliminará el servicio de StartOS pero conservará sus datos.',
|
||||
507: 'No hay proveedores guardados',
|
||||
508: 'Modo quiosco',
|
||||
509: 'Activado',
|
||||
510: 'Desactiva el modo quiosco a menos que necesites conectar un monitor',
|
||||
511: 'Activa el modo quiosco si necesitas conectar un monitor',
|
||||
512: 'El modo quiosco no está disponible en este dispositivo',
|
||||
513: 'Activar',
|
||||
514: 'Desactivar',
|
||||
515: 'Actualmente estás utilizando un quiosco. Desactivar el modo quiosco provocará su desconexión.',
|
||||
516: 'Recomendado',
|
||||
517: '¿Estás seguro de que deseas descartar esta tarea?',
|
||||
518: 'Descartar',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -60,7 +60,7 @@ export default {
|
||||
57: 'Arrêt initié',
|
||||
58: 'Ajouter',
|
||||
59: 'OK',
|
||||
60: 'Voulez-vous vraiment supprimer cette entrée ?',
|
||||
60: 'français',
|
||||
61: 'Cette valeur ne peut plus être modifiée une fois définie',
|
||||
62: 'Continuer',
|
||||
63: 'Cliquez ou déposez le fichier ici',
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
82: 'Métriques',
|
||||
83: 'Journaux',
|
||||
84: 'Notifications',
|
||||
85: 'Lancer l’interface utilisateur',
|
||||
85: 'Désinstallation forcée',
|
||||
86: 'Afficher le QR',
|
||||
87: 'Copier l’URL',
|
||||
88: 'Actions',
|
||||
@@ -230,9 +230,9 @@ export default {
|
||||
227: 'Erreur inconnue',
|
||||
228: 'Erreur',
|
||||
229: '« Reconstruire le conteneur » est une action sans risque qui ne prend que quelques secondes. Cela résoudra probablement ce problème.',
|
||||
230: '« Désinstaller le service » est une action risquée qui supprimera le service de StartOS et effacera toutes ses données.',
|
||||
230: '« Désinstallation forcée » est une action risquée qui supprimera le service de StartOS et effacera toutes ses données.',
|
||||
231: 'Reconstruire le conteneur',
|
||||
232: 'Désinstaller le service',
|
||||
232: 'Désinstallation douce',
|
||||
233: 'Voir le message complet',
|
||||
234: 'Erreur du service',
|
||||
235: 'En attente du résultat',
|
||||
@@ -247,7 +247,6 @@ export default {
|
||||
244: 'Hébergement',
|
||||
245: 'Installation',
|
||||
246: 'Voir ci-dessous',
|
||||
247: 'Contrôles',
|
||||
248: 'Aucun service installé',
|
||||
249: 'En fonctionnement',
|
||||
250: 'Arrêté',
|
||||
@@ -414,12 +413,12 @@ export default {
|
||||
411: 'Autres réseaux',
|
||||
412: 'Le WiFi est désactivé',
|
||||
413: 'Aucune interface sans fil détectée',
|
||||
414: 'Activation du WiFi',
|
||||
415: 'Désactivation du WiFi',
|
||||
414: 'Activation',
|
||||
415: 'Désactivation',
|
||||
416: 'Connexion en cours. Cela peut prendre un certain temps',
|
||||
417: 'Réessayer',
|
||||
418: 'Afficher plus',
|
||||
419: 'Notes de version',
|
||||
419: 'Voir les détails',
|
||||
420: 'Voir la fiche',
|
||||
421: 'Services dépendants de',
|
||||
422: 'ne fonctionneront plus correctement et pourraient planter.',
|
||||
@@ -503,5 +502,20 @@ export default {
|
||||
500: 'Voir la bibliothèque de services',
|
||||
501: 'Bienvenue sur',
|
||||
502: 'informatique souveraine',
|
||||
503: 'français',
|
||||
503: 'Personnalisez le nom qui apparaît dans l’onglet de votre navigateur',
|
||||
504: 'Gérer',
|
||||
505: 'Êtes-vous sûr de vouloir supprimer cette adresse ?',
|
||||
506: '« Désinstallation douce » supprimera le service de StartOS tout en conservant ses données.',
|
||||
507: 'Aucun fournisseur enregistré',
|
||||
508: 'Mode kiosque',
|
||||
509: 'Activé',
|
||||
510: 'Désactivez le mode kiosque sauf si vous devez connecter un moniteur',
|
||||
511: 'Activez le mode kiosque si vous devez connecter un moniteur',
|
||||
512: 'Le mode kiosque n’est pas disponible sur cet appareil',
|
||||
513: 'Activer',
|
||||
514: 'Désactiver',
|
||||
515: 'Vous utilisez actuellement un kiosque. Désactiver le mode kiosque entraînera sa déconnexion.',
|
||||
516: 'Recommandé',
|
||||
517: 'Êtes-vous sûr de vouloir ignorer cette tâche ?',
|
||||
518: 'Ignorer',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -60,7 +60,7 @@ export default {
|
||||
57: 'Rozpoczynanie wyłączania',
|
||||
58: 'Dodaj',
|
||||
59: 'OK',
|
||||
60: 'Czy na pewno chcesz usunąć ten wpis?',
|
||||
60: 'francuski',
|
||||
61: 'Ta wartość nie może być zmieniona po jej ustawieniu',
|
||||
62: 'Kontynuuj',
|
||||
63: 'Kliknij lub upuść plik tutaj',
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
82: 'Monitorowanie',
|
||||
83: 'Logi',
|
||||
84: 'Powiadomienia',
|
||||
85: 'Uruchom interfejs',
|
||||
85: 'Twarde odinstalowanie',
|
||||
86: 'Pokaż kod QR',
|
||||
87: 'Kopiuj URL',
|
||||
88: 'Akcje',
|
||||
@@ -230,9 +230,9 @@ export default {
|
||||
227: 'Nieznany błąd',
|
||||
228: 'Błąd',
|
||||
229: '„Odbuduj kontener” to bezpieczna akcja, która zajmuje tylko kilka sekund. Prawdopodobnie rozwiąże ten problem.',
|
||||
230: '„Odinstaluj serwis” to niebezpieczna akcja, która usunie serwis ze StartOS i trwale usunie wszystkie jego dane.',
|
||||
230: '„Twarde odinstalowanie” to niebezpieczna akcja, która usunie serwis ze StartOS i trwale usunie wszystkie jego dane.',
|
||||
231: 'Odbuduj kontener',
|
||||
232: 'Odinstaluj serwis',
|
||||
232: 'Miękkie odinstalowanie',
|
||||
233: 'Zobacz pełną wiadomość',
|
||||
234: 'Błąd serwisu',
|
||||
235: 'Oczekiwanie na wynik',
|
||||
@@ -247,7 +247,6 @@ export default {
|
||||
244: 'Hosting',
|
||||
245: 'Instalowanie',
|
||||
246: 'Zobacz poniżej',
|
||||
247: 'Sterowanie',
|
||||
248: 'Brak zainstalowanych serwisów',
|
||||
249: 'Uruchomiony',
|
||||
250: 'Zatrzymany',
|
||||
@@ -414,12 +413,12 @@ export default {
|
||||
411: 'Inne sieci',
|
||||
412: 'WiFi jest wyłączone',
|
||||
413: 'Nie wykryto interfejsu bezprzewodowego',
|
||||
414: 'Włączanie WiFi',
|
||||
415: 'Wyłączanie WiFi',
|
||||
414: 'Włączanie',
|
||||
415: 'Wyłączanie',
|
||||
416: 'Łączenie. To może chwilę potrwać',
|
||||
417: 'Ponów próbę',
|
||||
418: 'Pokaż więcej',
|
||||
419: 'Informacje o wydaniu',
|
||||
419: 'Zobacz szczegóły',
|
||||
420: 'Zobacz listę',
|
||||
421: 'Serwisy zależne od',
|
||||
422: 'przestaną działać poprawnie i mogą ulec awarii.',
|
||||
@@ -503,5 +502,20 @@ export default {
|
||||
500: 'Zobacz Rynek',
|
||||
501: 'Witamy w',
|
||||
502: 'suwerenne przetwarzanie',
|
||||
503: 'francuski',
|
||||
503: 'Dostosuj nazwę wyświetlaną na karcie przeglądarki',
|
||||
504: 'Zarządzać',
|
||||
505: 'Czy na pewno chcesz usunąć ten adres?',
|
||||
506: '„Miękkie odinstalowanie” usunie usługę z StartOS, ale zachowa jej dane.',
|
||||
507: 'Brak zapisanych dostawców',
|
||||
508: 'Tryb kiosku',
|
||||
509: 'Włączony',
|
||||
510: 'Wyłącz tryb kiosku, chyba że potrzebujesz podłączyć monitor',
|
||||
511: 'Włącz tryb kiosku, jeśli potrzebujesz podłączyć monitor',
|
||||
512: 'Tryb kiosku jest niedostępny na tym urządzeniu',
|
||||
513: 'Włącz',
|
||||
514: 'Wyłącz',
|
||||
515: 'Obecnie używasz kiosku. Wyłączenie trybu kiosku spowoduje jego rozłączenie.',
|
||||
516: 'Zalecane',
|
||||
517: 'Czy na pewno chcesz odrzucić to zadanie?',
|
||||
518: 'Odrzuć',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -11,8 +11,6 @@ import { I18N, i18nKey } from './i18n.providers'
|
||||
export class i18nPipe implements PipeTransform {
|
||||
private readonly i18n = inject(I18N)
|
||||
|
||||
// @TODO uncomment to make sure translations are present
|
||||
// transform(englishKey: string | null | undefined): string | undefined {
|
||||
transform(englishKey: i18nKey | null | undefined): string | undefined {
|
||||
return englishKey
|
||||
? this.i18n()?.[ENGLISH[englishKey as i18nKey]] || englishKey
|
||||
|
||||
@@ -34,5 +34,11 @@ export class i18nService extends TuiLanguageSwitcherService {
|
||||
}
|
||||
}
|
||||
|
||||
export const languages = ['english', 'spanish', 'polish', 'german', 'french'] as const
|
||||
export const languages = [
|
||||
'english',
|
||||
'spanish',
|
||||
'polish',
|
||||
'german',
|
||||
'french',
|
||||
] as const
|
||||
export type Languages = (typeof languages)[number]
|
||||
|
||||
@@ -12,13 +12,15 @@ export function formatProgress({ phases, overall }: T.FullProgress): {
|
||||
p,
|
||||
): p is {
|
||||
name: string
|
||||
progress: {
|
||||
done: number
|
||||
total: number | null
|
||||
}
|
||||
progress:
|
||||
| false
|
||||
| {
|
||||
done: number
|
||||
total: number | null
|
||||
}
|
||||
} => p.progress !== true && p.progress !== null,
|
||||
)
|
||||
.map(p => `<b>${p.name}</b>${getPhaseBytes(p.progress)}`)
|
||||
.map(p => `<b>${p.name}</b>: (${getPhaseBytes(p.progress)})`)
|
||||
.join(', '),
|
||||
}
|
||||
}
|
||||
@@ -33,8 +35,13 @@ function getDecimal(progress: T.Progress): number {
|
||||
}
|
||||
}
|
||||
|
||||
function getPhaseBytes(progress: T.Progress): string {
|
||||
return progress === true || !progress
|
||||
? ''
|
||||
: `: (${progress.done}/${progress.total})`
|
||||
function getPhaseBytes(
|
||||
progress:
|
||||
| false
|
||||
| {
|
||||
done: number
|
||||
total: number | null
|
||||
},
|
||||
): string {
|
||||
return !progress ? 'unknown' : `${progress.done}/${progress.total}`
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@ body {
|
||||
vertical-align: bottom;
|
||||
animation: ellipsis-dot 1s infinite 0.3s;
|
||||
animation-fill-mode: forwards;
|
||||
text-align: left;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
[tuiAppearance][data-appearance='primary-success'] {
|
||||
color: var(--tui-text-primary-on-accent-1);
|
||||
background: var(--tui-status-positive);
|
||||
|
||||
@include appearance-hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
@include appearance-active {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
@include appearance-disabled {
|
||||
background: var(--tui-status-neutral);
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
tui-hint[data-appearance='onDark'] {
|
||||
background: white !important;
|
||||
color: #222 !important;
|
||||
|
||||
@@ -48,6 +48,6 @@ export default class InitializingPage {
|
||||
return caught$
|
||||
}),
|
||||
),
|
||||
{ initialValue: { total: 0, message: '' } },
|
||||
{ initialValue: { total: 0, message: 'waiting...' } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ export class LoginPage {
|
||||
} catch (e: any) {
|
||||
// code 7 is for incorrect password
|
||||
this.error = e.code === 7 ? 'Invalid password' : (e.message as i18nKey)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,20 +73,7 @@ export class FormArrayComponent {
|
||||
}
|
||||
|
||||
removeAt(index: number) {
|
||||
this.dialog
|
||||
.openConfirm<boolean>({
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Are you sure you want to delete this entry?',
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => {
|
||||
this.removeItem(index)
|
||||
})
|
||||
this.removeItem(index)
|
||||
}
|
||||
|
||||
private removeItem(index: number) {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { ABOUT } from './about.component'
|
||||
appearance=""
|
||||
tuiHintDirection="bottom"
|
||||
[tuiHint]="open ? '' : ('Start Menu' | i18n)"
|
||||
[tuiHintShowDelay]="1000"
|
||||
[tuiHintShowDelay]="750"
|
||||
[tuiDropdown]="content"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
[tuiDropdownMaxHeight]="9999"
|
||||
|
||||
@@ -23,7 +23,7 @@ import { getMenu } from 'src/app/utils/system-utilities'
|
||||
class="link"
|
||||
routerLinkActive="link_active"
|
||||
tuiHintDirection="bottom"
|
||||
[tuiHintShowDelay]="1000"
|
||||
[tuiHintShowDelay]="750"
|
||||
[routerLink]="item.routerLink"
|
||||
[class.link_system]="item.routerLink === '/portal/system'"
|
||||
[tuiHint]="rla.isActive ? '' : (item.name | i18n)"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
|
||||
import { InterfaceComponent } from './interface.component'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -22,24 +23,30 @@ import { InterfaceComponent } from './interface.component'
|
||||
template: `
|
||||
<div class="desktop">
|
||||
<ng-content />
|
||||
@if (interface.serviceInterface().type === 'ui') {
|
||||
<a
|
||||
@if (interface.value().type === 'ui') {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.external-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[href]="actions()"
|
||||
[disabled]="disabled()"
|
||||
(click)="openUI()"
|
||||
>
|
||||
{{ 'Launch UI' | i18n }}
|
||||
</a>
|
||||
{{ 'Open' | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button tuiIconButton iconStart="@tui.qr-code" (click)="showQR()">
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.qr-code"
|
||||
(click)="showQR()"
|
||||
>
|
||||
{{ 'Show QR' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.copy"
|
||||
(click)="copyService.copy(actions())"
|
||||
(click)="copyService.copy(href())"
|
||||
>
|
||||
{{ 'Copy URL' | i18n }}
|
||||
</button>
|
||||
@@ -55,27 +62,26 @@ import { InterfaceComponent } from './interface.component'
|
||||
<ng-template #dropdown let-close>
|
||||
<tui-data-list>
|
||||
<tui-opt-group>
|
||||
@if (interface.serviceInterface().type === 'ui') {
|
||||
<a
|
||||
tuiOption
|
||||
iconStart="@tui.external-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[href]="actions()"
|
||||
>
|
||||
{{ 'Launch UI' | i18n }}
|
||||
</a>
|
||||
<button tuiOption iconStart="@tui.qr-code" (click)="showQR()">
|
||||
{{ 'Show QR' | i18n }}
|
||||
</button>
|
||||
@if (interface.value().type === 'ui') {
|
||||
<button
|
||||
tuiOption
|
||||
iconStart="@tui.copy"
|
||||
(click)="copyService.copy(actions()); close()"
|
||||
iconStart="@tui.external-link"
|
||||
[disabled]="disabled()"
|
||||
(click)="openUI()"
|
||||
>
|
||||
{{ 'Copy URL' | i18n }}
|
||||
{{ 'Open' | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button tuiOption iconStart="@tui.qr-code" (click)="showQR()">
|
||||
{{ 'Show QR' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
iconStart="@tui.copy"
|
||||
(click)="copyService.copy(href()); close()"
|
||||
>
|
||||
{{ 'Copy URL' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group><ng-content select="[tuiOption]" /></tui-opt-group>
|
||||
</tui-data-list>
|
||||
@@ -110,20 +116,27 @@ import { InterfaceComponent } from './interface.component'
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InterfaceActionsComponent {
|
||||
private readonly document = inject(DOCUMENT)
|
||||
|
||||
readonly isMobile = inject(TUI_IS_MOBILE)
|
||||
readonly dialog = inject(DialogService)
|
||||
readonly copyService = inject(CopyService)
|
||||
readonly interface = inject(InterfaceComponent)
|
||||
|
||||
readonly actions = input.required<string>()
|
||||
readonly href = input.required<string>()
|
||||
readonly disabled = input.required<boolean>()
|
||||
|
||||
showQR() {
|
||||
this.dialog
|
||||
.openComponent(new PolymorpheusComponent(QRModal), {
|
||||
size: 'auto',
|
||||
closeable: this.isMobile,
|
||||
data: this.actions(),
|
||||
data: this.href(),
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
openUI() {
|
||||
this.document.defaultView?.open(this.href(), '_blank', 'noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import {
|
||||
DialogService,
|
||||
DocsLinkDirective,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
@@ -59,22 +59,12 @@ type ClearnetForm = {
|
||||
}}
|
||||
<a
|
||||
tuiLink
|
||||
docsLink
|
||||
href="/user-manual/connecting-remotely/clearnet.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{{ 'Learn more' | i18n }}
|
||||
</a>
|
||||
</ng-template>
|
||||
<button
|
||||
tuiButton
|
||||
[appearance]="isPublic() ? 'primary-destructive' : 'accent'"
|
||||
[iconStart]="isPublic() ? '@tui.globe-lock' : '@tui.globe'"
|
||||
[style.margin-inline-start]="'auto'"
|
||||
(click)="toggle()"
|
||||
>
|
||||
{{ isPublic() ? ('Make private' | i18n) : ('Make public' | i18n) }}
|
||||
</button>
|
||||
@if (clearnet().length) {
|
||||
<button tuiButton iconStart="@tui.plus" (click)="add()">
|
||||
{{ 'Add' | i18n }}
|
||||
@@ -86,18 +76,15 @@ type ClearnetForm = {
|
||||
@for (address of clearnet(); track $index) {
|
||||
<tr>
|
||||
<td [style.width.rem]="12">
|
||||
{{
|
||||
interface.serviceInterface().addSsl
|
||||
? (address.acme | acme)
|
||||
: '-'
|
||||
}}
|
||||
{{ interface.value().addSsl ? (address.acme | acme) : '-' }}
|
||||
</td>
|
||||
<td>{{ address.url | mask }}</td>
|
||||
<td [actions]="address.url">
|
||||
<td actions [href]="address.url" [disabled]="!isRunning()">
|
||||
@if (address.isDomain) {
|
||||
<button
|
||||
tuiButton
|
||||
appearance="primary-destructive"
|
||||
tuiIconButton
|
||||
iconStart="@tui.trash"
|
||||
appearance="flat-grayscale"
|
||||
[style.margin-inline-end.rem]="0.5"
|
||||
(click)="remove(address)"
|
||||
>
|
||||
@@ -141,6 +128,7 @@ type ClearnetForm = {
|
||||
AcmePipe,
|
||||
InterfaceActionsComponent,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -152,9 +140,10 @@ export class InterfaceClearnetComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
readonly interface = inject(InterfaceComponent)
|
||||
readonly isPublic = computed(() => this.interface.serviceInterface().public)
|
||||
|
||||
readonly clearnet = input.required<readonly ClearnetAddress[]>()
|
||||
readonly isRunning = input.required<boolean>()
|
||||
|
||||
readonly acme = toSignal(
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'network', 'acme')
|
||||
@@ -165,7 +154,15 @@ export class InterfaceClearnetComponent {
|
||||
async remove({ url }: ClearnetAddress) {
|
||||
const confirm = await firstValueFrom(
|
||||
this.dialog
|
||||
.openConfirm({ label: 'Are you sure?', size: 's' })
|
||||
.openConfirm({
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
content: 'Are you sure you want to delete this address?',
|
||||
},
|
||||
})
|
||||
.pipe(defaultIfEmpty(false)),
|
||||
)
|
||||
|
||||
@@ -181,7 +178,7 @@ export class InterfaceClearnetComponent {
|
||||
await this.api.pkgRemoveDomain({
|
||||
...params,
|
||||
package: this.interface.packageId(),
|
||||
host: this.interface.serviceInterface().addressInfo.hostId,
|
||||
host: this.interface.value().addressInfo.hostId,
|
||||
})
|
||||
} else {
|
||||
await this.api.serverRemoveDomain(params)
|
||||
@@ -195,33 +192,6 @@ export class InterfaceClearnetComponent {
|
||||
}
|
||||
}
|
||||
|
||||
async toggle() {
|
||||
const loader = this.loader
|
||||
.open(`Making ${this.isPublic() ? 'private' : 'public'}`)
|
||||
.subscribe()
|
||||
|
||||
const params = {
|
||||
internalPort: this.interface.serviceInterface().addressInfo.internalPort,
|
||||
public: !this.isPublic(),
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.interface.packageId()) {
|
||||
await this.api.pkgBindingSetPubic({
|
||||
...params,
|
||||
host: this.interface.serviceInterface().addressInfo.hostId,
|
||||
package: this.interface.packageId(),
|
||||
})
|
||||
} else {
|
||||
await this.api.serverBindingSetPubic(params)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async add() {
|
||||
const domain = ISB.Value.text({
|
||||
name: 'Domain',
|
||||
@@ -250,9 +220,7 @@ export class InterfaceClearnetComponent {
|
||||
data: {
|
||||
spec: await configBuilderToSpec(
|
||||
ISB.InputSpec.of(
|
||||
this.interface.serviceInterface().addSsl
|
||||
? { domain, acme }
|
||||
: { domain },
|
||||
this.interface.value().addSsl ? { domain, acme } : { domain },
|
||||
),
|
||||
),
|
||||
buttons: [
|
||||
@@ -281,7 +249,7 @@ export class InterfaceClearnetComponent {
|
||||
await this.api.pkgAddDomain({
|
||||
...params,
|
||||
package: this.interface.packageId(),
|
||||
host: this.interface.serviceInterface().addressInfo.hostId,
|
||||
host: this.interface.value().addressInfo.hostId,
|
||||
})
|
||||
} else {
|
||||
await this.api.serverAddDomain(params)
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
|
||||
import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core'
|
||||
import { InterfaceClearnetComponent } from 'src/app/routes/portal/components/interfaces/clearnet.component'
|
||||
import { InterfaceLocalComponent } from 'src/app/routes/portal/components/interfaces/local.component'
|
||||
import { InterfaceTorComponent } from 'src/app/routes/portal/components/interfaces/tor.component'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { MappedServiceInterface } from './interface.utils'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-interface',
|
||||
template: `
|
||||
<section [clearnet]="serviceInterface().addresses.clearnet"></section>
|
||||
<section [tor]="serviceInterface().addresses.tor"></section>
|
||||
<section [local]="serviceInterface().addresses.local"></section>
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
[appearance]="value().public ? 'primary-destructive' : 'primary-success'"
|
||||
[iconStart]="value().public ? '@tui.globe-lock' : '@tui.globe'"
|
||||
(click)="toggle()"
|
||||
>
|
||||
{{ value().public ? ('Make private' | i18n) : ('Make public' | i18n) }}
|
||||
</button>
|
||||
<section
|
||||
[clearnet]="value().addresses.clearnet"
|
||||
[isRunning]="isRunning()"
|
||||
></section>
|
||||
<section [tor]="value().addresses.tor" [isRunning]="isRunning()"></section>
|
||||
<section
|
||||
[local]="value().addresses.local"
|
||||
[isRunning]="isRunning()"
|
||||
></section>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
@@ -19,6 +41,12 @@ import { MappedServiceInterface } from './interface.utils'
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: var(--tui-text-secondary);
|
||||
font: var(--tui-font-text-l);
|
||||
}
|
||||
|
||||
button {
|
||||
margin: -0.5rem auto 0 0;
|
||||
}
|
||||
`,
|
||||
providers: [tuiButtonOptionsProvider({ size: 'xs' })],
|
||||
@@ -27,9 +55,43 @@ import { MappedServiceInterface } from './interface.utils'
|
||||
InterfaceClearnetComponent,
|
||||
InterfaceTorComponent,
|
||||
InterfaceLocalComponent,
|
||||
TuiButton,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export class InterfaceComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
readonly packageId = input('')
|
||||
readonly serviceInterface = input.required<MappedServiceInterface>()
|
||||
readonly value = input.required<MappedServiceInterface>()
|
||||
readonly isRunning = input.required<boolean>()
|
||||
|
||||
async toggle() {
|
||||
const loader = this.loader
|
||||
.open(`Making ${this.value().public ? 'private' : 'public'}`)
|
||||
.subscribe()
|
||||
|
||||
const params = {
|
||||
internalPort: this.value().addressInfo.internalPort,
|
||||
public: !this.value().public,
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.packageId()) {
|
||||
await this.api.pkgBindingSetPubic({
|
||||
...params,
|
||||
host: this.value().addressInfo.hostId,
|
||||
package: this.packageId(),
|
||||
})
|
||||
} else {
|
||||
await this.api.serverBindingSetPubic(params)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
|
||||
<tr>
|
||||
<td [style.width.rem]="12">{{ address.nid }}</td>
|
||||
<td>{{ address.url | mask }}</td>
|
||||
<td [actions]="address.url"></td>
|
||||
<td actions [href]="address.url" [disabled]="!isRunning()"></td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
@@ -49,4 +49,5 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
|
||||
})
|
||||
export class InterfaceLocalComponent {
|
||||
readonly local = input.required<readonly LocalAddress[]>()
|
||||
readonly isRunning = input.required<boolean>()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export class MaskPipe implements PipeTransform {
|
||||
private readonly interface = inject(InterfaceComponent)
|
||||
|
||||
transform(value: string): string {
|
||||
return this.interface.serviceInterface().masked
|
||||
return this.interface.value().masked
|
||||
? '●'.repeat(Math.min(64, value.length))
|
||||
: value
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
<tui-badge
|
||||
size="l"
|
||||
[iconStart]="public() ? '@tui.globe' : '@tui.lock'"
|
||||
[style.vertical-align.rem]="-0.125"
|
||||
[style.margin]="'0 0.25rem -0.25rem'"
|
||||
[appearance]="public() ? 'positive' : 'negative'"
|
||||
>
|
||||
{{ public() ? ('Public' | i18n) : ('Private' | i18n) }}
|
||||
</tui-badge>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiBadge, i18nPipe],
|
||||
})
|
||||
|
||||
@@ -76,11 +76,11 @@ type OnionForm = {
|
||||
{{ address.url | mask }}
|
||||
</div>
|
||||
</td>
|
||||
<td [actions]="address.url">
|
||||
<td actions [href]="address.url" [disabled]="!isRunning()">
|
||||
<button
|
||||
tuiButton
|
||||
appearance="primary-destructive"
|
||||
[style.margin-inline-end.rem]="0.5"
|
||||
tuiIconButton
|
||||
iconStart="@tui.trash"
|
||||
appearance="flat-grayscale"
|
||||
(click)="remove(address)"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
@@ -141,11 +141,20 @@ export class InterfaceTorComponent {
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
readonly tor = input.required<readonly TorAddress[]>()
|
||||
readonly isRunning = input.required<boolean>()
|
||||
|
||||
async remove({ url }: TorAddress) {
|
||||
const confirm = await firstValueFrom(
|
||||
this.dialog
|
||||
.openConfirm({ label: 'Are you sure?', size: 's' })
|
||||
.openConfirm({
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
content: 'Are you sure you want to delete this address?',
|
||||
},
|
||||
})
|
||||
.pipe(defaultIfEmpty(false)),
|
||||
)
|
||||
|
||||
@@ -161,7 +170,7 @@ export class InterfaceTorComponent {
|
||||
await this.api.pkgRemoveOnion({
|
||||
...params,
|
||||
package: this.interface.packageId(),
|
||||
host: this.interface.serviceInterface().addressInfo.hostId,
|
||||
host: this.interface.value().addressInfo.hostId,
|
||||
})
|
||||
} else {
|
||||
await this.api.serverRemoveOnion(params)
|
||||
@@ -215,7 +224,7 @@ export class InterfaceTorComponent {
|
||||
await this.api.pkgAddOnion({
|
||||
onion,
|
||||
package: this.interface.packageId(),
|
||||
host: this.interface.serviceInterface().addressInfo.hostId,
|
||||
host: this.interface.value().addressInfo.hostId,
|
||||
})
|
||||
} else {
|
||||
await this.api.serverAddOnion({ onion })
|
||||
|
||||
@@ -62,6 +62,13 @@ import { HeaderComponent } from './components/header/header.component'
|
||||
flex-direction: column;
|
||||
// @TODO Theme
|
||||
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
|
||||
@@ -30,7 +30,7 @@ const ROUTES: Routes = [
|
||||
{
|
||||
title: titleResolver,
|
||||
path: 'logs',
|
||||
loadComponent: () => import('./routes/logs/logs.component'),
|
||||
loadChildren: () => import('./routes/logs/logs.routes'),
|
||||
data: toNavigationItem('/portal/logs'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'logs-header',
|
||||
template: `
|
||||
<ng-container *title>
|
||||
<a tuiIconButton size="m" iconStart="@tui.arrow-left" routerLink="..">
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
{{ title() }}
|
||||
</ng-container>
|
||||
<hgroup tuiTitle>
|
||||
<h3>{{ title() }}</h3>
|
||||
<p tuiSubtitle><ng-content /></p>
|
||||
</hgroup>
|
||||
<aside tuiAccessories>
|
||||
<a
|
||||
tuiIconButton
|
||||
appearance="secondary-grayscale"
|
||||
iconStart="@tui.x"
|
||||
size="s"
|
||||
routerLink=".."
|
||||
[style.border-radius.%]="100"
|
||||
>
|
||||
{{ 'Close' | i18n }}
|
||||
</a>
|
||||
</aside>
|
||||
`,
|
||||
styles: `
|
||||
logs-header[tuiHeader] {
|
||||
margin-block-end: 1rem;
|
||||
|
||||
+ logs {
|
||||
height: calc(100% - 5rem);
|
||||
}
|
||||
|
||||
tui-root._mobile & {
|
||||
display: none;
|
||||
|
||||
+ logs {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, TuiTitle, i18nPipe, RouterLink, TitleDirective],
|
||||
hostDirectives: [TuiHeader],
|
||||
})
|
||||
export class LogsHeaderComponent {
|
||||
readonly title = input<string | undefined>()
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { KeyValuePipe } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { i18nKey, i18nPipe } from '@start9labs/shared'
|
||||
import { TuiAppearance, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiCardMedium } from '@taiga-ui/layout'
|
||||
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
|
||||
interface Log {
|
||||
title: i18nKey
|
||||
subtitle: i18nKey
|
||||
icon: string
|
||||
follow: (params: RR.FollowServerLogsReq) => Promise<RR.FollowServerLogsRes>
|
||||
fetch: (params: RR.GetServerLogsReq) => Promise<RR.GetServerLogsRes>
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *title>
|
||||
@if (current(); as key) {
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.arrow-left"
|
||||
(click)="current.set(null)"
|
||||
>
|
||||
{{ 'Back' | i18n }}
|
||||
</button>
|
||||
{{ logs[key]?.title | i18n }}
|
||||
} @else {
|
||||
{{ 'Logs' | i18n }}
|
||||
}
|
||||
</ng-container>
|
||||
@if (current(); as key) {
|
||||
<header tuiTitle>
|
||||
<strong class="title">
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="secondary-grayscale"
|
||||
iconStart="@tui.x"
|
||||
size="s"
|
||||
class="close"
|
||||
(click)="current.set(null)"
|
||||
>
|
||||
{{ 'Close' | i18n }}
|
||||
</button>
|
||||
{{ logs[key]?.title | i18n }}
|
||||
</strong>
|
||||
<p tuiSubtitle>{{ logs[key]?.subtitle | i18n }}</p>
|
||||
</header>
|
||||
@for (log of logs | keyvalue; track $index) {
|
||||
@if (log.key === current()) {
|
||||
<logs
|
||||
[context]="log.key"
|
||||
[followLogs]="log.value.follow"
|
||||
[fetchLogs]="log.value.fetch"
|
||||
/>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
@for (log of logs | keyvalue; track $index) {
|
||||
<button
|
||||
tuiCardMedium
|
||||
tuiAppearance="neutral"
|
||||
(click)="current.set(log.key)"
|
||||
>
|
||||
<tui-icon [icon]="log.value.icon" />
|
||||
<span tuiTitle>
|
||||
<strong>{{ log.value.title | i18n }}</strong>
|
||||
<span tuiSubtitle>{{ log.value.subtitle | i18n }}</span>
|
||||
</span>
|
||||
<tui-icon icon="@tui.chevron-right" />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
`,
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'g-page' },
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
header {
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
logs {
|
||||
height: calc(100% - 4rem);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
button::before {
|
||||
margin: 0 -0.25rem 0 -0.375rem;
|
||||
--tui-icon-size: 1.5rem;
|
||||
}
|
||||
|
||||
[tuiCardMedium] {
|
||||
height: 14rem;
|
||||
width: 14rem;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--tui-background-neutral-1),
|
||||
var(--tui-shadow-small);
|
||||
|
||||
[tuiSubtitle] {
|
||||
color: var(--tui-text-secondary);
|
||||
}
|
||||
|
||||
tui-icon:last-child {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
|
||||
header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
logs {
|
||||
height: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
[tuiCardMedium] {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
LogsComponent,
|
||||
TitleDirective,
|
||||
KeyValuePipe,
|
||||
TuiTitle,
|
||||
TuiCardMedium,
|
||||
TuiIcon,
|
||||
TuiAppearance,
|
||||
TuiButton,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class SystemLogsComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
readonly current = signal<string | null>(null)
|
||||
readonly logs: Record<string, Log> = {
|
||||
os: {
|
||||
title: 'OS Logs',
|
||||
subtitle: 'Raw, unfiltered operating system logs',
|
||||
icon: '@tui.square-dashed-bottom-code',
|
||||
follow: params => this.api.followServerLogs(params),
|
||||
fetch: params => this.api.getServerLogs(params),
|
||||
},
|
||||
kernel: {
|
||||
title: 'Kernel Logs',
|
||||
subtitle: 'Diagnostics for drivers and other kernel processes',
|
||||
icon: '@tui.square-chevron-right',
|
||||
follow: params => this.api.followKernelLogs(params),
|
||||
fetch: params => this.api.getKernelLogs(params),
|
||||
},
|
||||
tor: {
|
||||
title: 'Tor Logs',
|
||||
subtitle: 'Diagnostic logs for the Tor daemon on StartOS',
|
||||
icon: '@tui.globe',
|
||||
follow: params => this.api.followTorLogs(params),
|
||||
fetch: params => this.api.getTorLogs(params),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Routes } from '@angular/router'
|
||||
|
||||
export const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./routes/outlet.component'),
|
||||
},
|
||||
{
|
||||
path: 'kernel',
|
||||
loadComponent: () => import('./routes/kernel.component'),
|
||||
},
|
||||
{
|
||||
path: 'os',
|
||||
loadComponent: () => import('./routes/os.component'),
|
||||
},
|
||||
{
|
||||
path: 'tor',
|
||||
loadComponent: () => import('./routes/tor.component'),
|
||||
},
|
||||
]
|
||||
|
||||
export default ROUTES
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
import { LogsHeaderComponent } from '../components/header.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<logs-header [title]="'Kernel Logs' | i18n">
|
||||
{{ 'Diagnostics for drivers and other kernel processes' | i18n }}
|
||||
</logs-header>
|
||||
<logs context="kernel" [followLogs]="follow" [fetchLogs]="fetch" />
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
padding: 1rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
|
||||
host: { class: 'g-page' },
|
||||
})
|
||||
export default class SystemKernelComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
protected readonly follow = (params: RR.FollowServerLogsReq) =>
|
||||
this.api.followKernelLogs(params)
|
||||
|
||||
protected readonly fetch = (params: RR.GetServerLogsReq) =>
|
||||
this.api.getKernelLogs(params)
|
||||
|
||||
log = {
|
||||
title: 'Kernel Logs',
|
||||
subtitle: 'Diagnostics for drivers and other kernel processes',
|
||||
icon: '@tui.square-chevron-right',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
|
||||
import { LogsHeaderComponent } from 'src/app/routes/portal/routes/logs/components/header.component'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<logs-header [title]="'OS Logs' | i18n">
|
||||
{{ 'Raw, unfiltered operating system logs' | i18n }}
|
||||
</logs-header>
|
||||
<logs context="os" [followLogs]="follow" [fetchLogs]="fetch" />
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
padding: 1rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
|
||||
host: { class: 'g-page' },
|
||||
})
|
||||
export default class SystemOSComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
protected readonly follow = (params: RR.FollowServerLogsReq) =>
|
||||
this.api.followServerLogs(params)
|
||||
|
||||
protected readonly fetch = (params: RR.GetServerLogsReq) =>
|
||||
this.api.getServerLogs(params)
|
||||
|
||||
log = {
|
||||
title: 'Kernel Logs',
|
||||
subtitle: 'Diagnostics for drivers and other kernel processes',
|
||||
icon: '@tui.square-chevron-right',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiAppearance, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiCardMedium } from '@taiga-ui/layout'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *title>{{ 'Logs' | i18n }}</ng-container>
|
||||
@for (log of logs; track $index) {
|
||||
<a tuiCardMedium tuiAppearance="neutral" [routerLink]="log.link">
|
||||
<tui-icon [icon]="log.icon" />
|
||||
<span tuiTitle>
|
||||
{{ log.title | i18n }}
|
||||
<span tuiSubtitle>{{ log.subtitle | i18n }}</span>
|
||||
</span>
|
||||
<tui-icon icon="@tui.chevron-right" />
|
||||
</a>
|
||||
}
|
||||
`,
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'g-page' },
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
[tuiCardMedium] {
|
||||
height: 14rem;
|
||||
width: 14rem;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--tui-background-neutral-1),
|
||||
var(--tui-shadow-small);
|
||||
|
||||
[tuiSubtitle] {
|
||||
color: var(--tui-text-secondary);
|
||||
}
|
||||
|
||||
tui-icon:last-child {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
|
||||
[tuiCardMedium] {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
RouterLink,
|
||||
TitleDirective,
|
||||
TuiTitle,
|
||||
TuiCardMedium,
|
||||
TuiIcon,
|
||||
TuiAppearance,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class SystemLogsComponent {
|
||||
readonly logs = [
|
||||
{
|
||||
link: 'os',
|
||||
title: 'OS Logs',
|
||||
subtitle: 'Raw, unfiltered operating system logs',
|
||||
icon: '@tui.square-dashed-bottom-code',
|
||||
},
|
||||
{
|
||||
link: 'kernel',
|
||||
title: 'Kernel Logs',
|
||||
subtitle: 'Diagnostics for drivers and other kernel processes',
|
||||
icon: '@tui.square-chevron-right',
|
||||
},
|
||||
{
|
||||
link: 'tor',
|
||||
title: 'Tor Logs',
|
||||
subtitle: 'Diagnostic logs for the Tor daemon on StartOS',
|
||||
icon: '@tui.globe',
|
||||
},
|
||||
] as const
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
|
||||
import { LogsHeaderComponent } from 'src/app/routes/portal/routes/logs/components/header.component'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<logs-header [title]="'Tor Logs' | i18n">
|
||||
{{ 'Diagnostic logs for the Tor daemon on StartOS' | i18n }}
|
||||
</logs-header>
|
||||
<logs context="tor" [followLogs]="follow" [fetchLogs]="fetch" />
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
padding: 1rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
|
||||
host: { class: 'g-page' },
|
||||
})
|
||||
export default class SystemOSComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
protected readonly follow = (params: RR.FollowServerLogsReq) =>
|
||||
this.api.followServerLogs(params)
|
||||
|
||||
protected readonly fetch = (params: RR.GetServerLogsReq) =>
|
||||
this.api.getServerLogs(params)
|
||||
|
||||
log = {
|
||||
title: 'Kernel Logs',
|
||||
subtitle: 'Diagnostics for drivers and other kernel processes',
|
||||
icon: '@tui.square-chevron-right',
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,15 @@ import { StorageService } from 'src/app/services/storage.service'
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
background: rgb(55 58 63 / 90%)
|
||||
url('/assets/img/background_marketplace.png') no-repeat top right;
|
||||
url('/assets/img/background_marketplace.jpg') no-repeat top right;
|
||||
background-size: cover;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(2rem);
|
||||
}
|
||||
}
|
||||
|
||||
.marketplace-content {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import {
|
||||
DialogService,
|
||||
Exver,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
MARKDOWN,
|
||||
SharedPipesModule,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
map,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
|
||||
@@ -59,10 +59,9 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
<marketplace-additional-item
|
||||
(click)="selectVersion(pkg, version)"
|
||||
[data]="('Click to view all versions' | i18n) || ''"
|
||||
[icon]="versions.length > 1 ? '@tui.chevron-right' : ''"
|
||||
icon="@tui.chevron-right"
|
||||
label="All versions"
|
||||
class="versions"
|
||||
[class.versions_empty]="versions.length < 2"
|
||||
/>
|
||||
<ng-template
|
||||
#version
|
||||
@@ -81,7 +80,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
(click)="completeWith(data.value)"
|
||||
(click)="completeWith(data.version)"
|
||||
>
|
||||
{{ 'Ok' | i18n }}
|
||||
</button>
|
||||
@@ -91,7 +90,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
</marketplace-additional>
|
||||
</div>
|
||||
} @else {
|
||||
<tui-loader class="loading" textContent="Loading" />
|
||||
<tui-loader textContent="Loading" [style.height.%]="100" />
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
@@ -114,7 +113,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
}
|
||||
|
||||
.listing {
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.8rem;
|
||||
// @TODO theme
|
||||
color: #8059e5;
|
||||
font-weight: 600;
|
||||
@@ -139,16 +138,6 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
::ng-deep label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&_empty {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
min-width: 30rem;
|
||||
height: 100%;
|
||||
place-self: center;
|
||||
}
|
||||
|
||||
marketplace-additional {
|
||||
@@ -254,6 +243,6 @@ export class MarketplacePreviewComponent {
|
||||
data: { version },
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(version => this.version$.next(version))
|
||||
.subscribe(selected => this.version$.next(selected))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<b>{{ value() || '-' }} C°</b>
|
||||
<b>{{ value() ? value() + ' C°' : 'N/A' }}</b>
|
||||
`,
|
||||
styles: `
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
@@ -55,7 +55,11 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
}
|
||||
@if (notificationItem.code === 1 || notificationItem.code === 2) {
|
||||
<button tuiLink (click)="service.viewModal(notificationItem)">
|
||||
{{ 'View report' | i18n }}
|
||||
{{
|
||||
notificationItem.code === 1
|
||||
? ('View report' | i18n)
|
||||
: ('View details' | i18n)
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
@@ -66,7 +70,7 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
'[class._new]': '!notificationItem.read',
|
||||
},
|
||||
styles: `
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
|
||||
|
||||
:host {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -90,8 +94,16 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem !important;
|
||||
|
||||
.checkbox {
|
||||
@include fullsize();
|
||||
@include taiga.fullsize();
|
||||
@include taiga.transition(box-shadow);
|
||||
|
||||
&:has(:checked) {
|
||||
box-shadow: inset 0.25rem 0 var(--tui-background-accent-1);
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
@@ -103,8 +115,7 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.service:not(:has(a)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
@@ -66,7 +67,7 @@ import { NotificationsTableComponent } from './table.component'
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class NotificationsComponent {
|
||||
export default class NotificationsComponent implements OnInit {
|
||||
private readonly router = inject(Router)
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
|
||||
@@ -74,16 +75,19 @@ export default class NotificationsComponent {
|
||||
readonly api = inject(ApiService)
|
||||
readonly errorService = inject(ErrorService)
|
||||
readonly notifications = signal<ServerNotifications | undefined>(undefined)
|
||||
readonly toast = this.route.queryParams.subscribe(params => {
|
||||
this.router.navigate([], { relativeTo: this.route, queryParams: {} })
|
||||
|
||||
if (isEmptyObject(params)) {
|
||||
this.getMore({})
|
||||
}
|
||||
})
|
||||
|
||||
open = false
|
||||
|
||||
ngOnInit() {
|
||||
this.route.queryParams.subscribe(params => {
|
||||
this.router.navigate([], { relativeTo: this.route, queryParams: {} })
|
||||
|
||||
if (isEmptyObject(params)) {
|
||||
this.getMore({})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getMore(params: RR.GetNotificationsReq) {
|
||||
try {
|
||||
this.notifications.set(undefined)
|
||||
|
||||
@@ -31,7 +31,7 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
/>
|
||||
</th>
|
||||
<th [style.min-width.rem]="12">{{ 'Date' | i18n }}</th>
|
||||
<th [style.min-width.rem]="12">{{ 'Title' | i18n }}</th>
|
||||
<th [style.min-width.rem]="14">{{ 'Title' | i18n }}</th>
|
||||
<th [style.min-width.rem]="8">{{ 'Service' | i18n }}</th>
|
||||
<th>{{ 'Message' | i18n }}</th>
|
||||
</tr>
|
||||
@@ -71,9 +71,13 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
styles: `
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host-context(tui-root._mobile) input {
|
||||
@include fullsize();
|
||||
opacity: 0;
|
||||
:host-context(tui-root._mobile) {
|
||||
margin: 0 -1rem;
|
||||
|
||||
input {
|
||||
@include fullsize();
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ActionItem {
|
||||
template: `
|
||||
<div tuiTitle>
|
||||
<strong>{{ action.name }}</strong>
|
||||
<div tuiSubtitle>{{ action.description }}</div>
|
||||
<div tuiSubtitle [innerHTML]="action.description"></div>
|
||||
@if (disabled) {
|
||||
<div tuiSubtitle class="g-warning">{{ disabled }}</div>
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
/>
|
||||
</tui-avatar>
|
||||
<span tuiTitle>
|
||||
{{ d.value.title }}
|
||||
{{ d.value.title || d.key }}
|
||||
@if (getError(d.key); as error) {
|
||||
<span tuiSubtitle class="g-warning">{{ error | i18n }}</span>
|
||||
} @else {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@angular/core'
|
||||
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton, TuiIcon } from '@taiga-ui/core'
|
||||
import { TuiLineClamp, TuiTooltip } from '@taiga-ui/kit'
|
||||
import { TuiTooltip } from '@taiga-ui/kit'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { StandardActionsService } from 'src/app/services/standard-actions.service'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
@@ -15,12 +15,9 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
standalone: true,
|
||||
selector: 'service-error',
|
||||
template: `
|
||||
<header>{{ 'Error' | i18n }}</header>
|
||||
<tui-line-clamp
|
||||
[linesLimit]="2"
|
||||
[content]="error?.message"
|
||||
(overflownChange)="overflow = $event"
|
||||
/>
|
||||
<header>{{ 'Service Launch Error' | i18n }}</header>
|
||||
<p class="error-message">{{ error?.message }}</p>
|
||||
<p>{{ error?.debug }}</p>
|
||||
<h4>
|
||||
{{ 'Actions' | i18n }}
|
||||
<tui-icon [tuiTooltip]="hint" />
|
||||
@@ -34,7 +31,13 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
'"Uninstall service" is a dangerous action that will remove the service from StartOS and wipe all its data.'
|
||||
'"Soft uninstall" will remove the service from StartOS but preserve its data.'
|
||||
| i18n
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
'"Hard uninstall" is a dangerous action that will remove the service from StartOS and wipe all its data.'
|
||||
| i18n
|
||||
}}
|
||||
</p>
|
||||
@@ -43,8 +46,11 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
<button tuiButton (click)="rebuild()">
|
||||
{{ 'Rebuild container' | i18n }}
|
||||
</button>
|
||||
<button tuiButton appearance="negative" (click)="uninstall()">
|
||||
{{ 'Uninstall service' | i18n }}
|
||||
<button tuiButton appearance="warning" (click)="uninstall()">
|
||||
{{ 'Soft uninstall' | i18n }}
|
||||
</button>
|
||||
<button tuiButton appearance="negative" (click)="uninstall(false)">
|
||||
{{ 'Hard uninstall' | i18n }}
|
||||
</button>
|
||||
@if (overflow) {
|
||||
<button tuiButton appearance="secondary-grayscale" (click)="show()">
|
||||
@@ -55,23 +61,21 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
grid-column: span 4;
|
||||
grid-column: span 5;
|
||||
}
|
||||
|
||||
header {
|
||||
--tui-background-neutral-1: var(--tui-status-negative-pale);
|
||||
}
|
||||
|
||||
tui-line-clamp {
|
||||
pointer-events: none;
|
||||
margin: 1rem 0;
|
||||
.error-message {
|
||||
font-size: 1.5rem;
|
||||
color: var(--tui-status-negative);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font: var(--tui-font-text-m);
|
||||
font-weight: bold;
|
||||
color: var(--tui-text-secondary);
|
||||
@@ -80,7 +84,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
`,
|
||||
host: { class: 'g-card' },
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, TuiIcon, TuiTooltip, TuiLineClamp, i18nPipe],
|
||||
imports: [TuiButton, TuiIcon, TuiTooltip, i18nPipe],
|
||||
})
|
||||
export class ServiceErrorComponent {
|
||||
private readonly dialog = inject(DialogService)
|
||||
@@ -99,8 +103,8 @@ export class ServiceErrorComponent {
|
||||
this.service.rebuild(getManifest(this.pkg).id)
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
this.service.uninstall(getManifest(this.pkg))
|
||||
uninstall(soft = true) {
|
||||
this.service.uninstall(getManifest(this.pkg), { force: true, soft })
|
||||
}
|
||||
|
||||
show() {
|
||||
|
||||
@@ -97,7 +97,7 @@ export class ServiceHealthCheckComponent {
|
||||
case 'starting':
|
||||
return this.i18n.transform('Starting')!
|
||||
case 'success':
|
||||
return `${this.i18n.transform('Success')}: ${this.healthCheck.message}`
|
||||
return `${this.i18n.transform('Success')}: ${this.healthCheck.message || 'health check passing'}`
|
||||
case 'loading':
|
||||
case 'failure':
|
||||
return this.healthCheck.message
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -5,9 +6,8 @@ import {
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiButton, TuiIcon, TuiLink } from '@taiga-ui/core'
|
||||
import { TuiButton, TuiIcon } from '@taiga-ui/core'
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
@@ -16,16 +16,11 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
selector: 'tr[serviceInterface]',
|
||||
template: `
|
||||
<td>
|
||||
<a tuiLink [routerLink]="info.routerLink">
|
||||
<strong>{{ info.name }}</strong>
|
||||
</a>
|
||||
<strong>{{ info.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<tui-badge size="m" [appearance]="appearance">{{ info.type }}</tui-badge>
|
||||
</td>
|
||||
<td class="g-secondary" [style.grid-area]="'2 / span 4'">
|
||||
{{ info.description }}
|
||||
</td>
|
||||
<td [style.text-align]="'center'">
|
||||
@if (info.public) {
|
||||
<tui-icon class="g-positive" icon="@tui.globe" />
|
||||
@@ -33,73 +28,70 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
<tui-icon class="g-negative" icon="@tui.lock" />
|
||||
}
|
||||
</td>
|
||||
<td [style.grid-area]="'span 2'">
|
||||
<td class="g-secondary" [style.grid-area]="'2 / span 4'">
|
||||
{{ info.description }}
|
||||
</td>
|
||||
<td>
|
||||
@if (info.type === 'ui') {
|
||||
<a
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="action"
|
||||
iconStart="@tui.external-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="s"
|
||||
[style.border-radius.%]="100"
|
||||
[attr.href]="href"
|
||||
(click.stop)="(0)"
|
||||
>
|
||||
{{ 'Open' | i18n }}
|
||||
</a>
|
||||
appearance="flat-grayscale"
|
||||
[disabled]="disabled"
|
||||
(click)="openUI()"
|
||||
></button>
|
||||
}
|
||||
<a
|
||||
tuiIconButton
|
||||
iconStart="@tui.settings"
|
||||
appearance="flat-grayscale"
|
||||
[routerLink]="info.routerLink"
|
||||
></a>
|
||||
</td>
|
||||
`,
|
||||
styles: `
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
cursor: pointer;
|
||||
clip-path: inset(0 round var(--tui-radius-m));
|
||||
@include transition(background);
|
||||
}
|
||||
|
||||
[tuiLink] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media ($tui-mouse) {
|
||||
:host:hover {
|
||||
background: var(--tui-background-neutral-1);
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tui-badge {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tui-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
grid-area: 3 / span 4;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, min-content) 1fr 2rem;
|
||||
grid-template-columns: repeat(3, min-content) 1fr;
|
||||
align-items: center;
|
||||
padding: 1rem 0.5rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
td {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiButton, TuiBadge, TuiLink, TuiIcon, RouterLink, i18nPipe],
|
||||
imports: [TuiButton, TuiBadge, TuiIcon, RouterLink],
|
||||
})
|
||||
export class ServiceInterfaceComponent {
|
||||
export class ServiceInterfaceItemComponent {
|
||||
private readonly config = inject(ConfigService)
|
||||
private readonly document = inject(DOCUMENT)
|
||||
|
||||
@Input({ required: true })
|
||||
info!: T.ServiceInterface & {
|
||||
@@ -116,17 +108,19 @@ export class ServiceInterfaceComponent {
|
||||
get appearance(): string {
|
||||
switch (this.info.type) {
|
||||
case 'ui':
|
||||
return 'primary'
|
||||
return 'positive'
|
||||
case 'api':
|
||||
return 'accent'
|
||||
return 'info'
|
||||
case 'p2p':
|
||||
return 'primary-grayscale'
|
||||
return 'negative'
|
||||
}
|
||||
}
|
||||
|
||||
get href(): string | null {
|
||||
return this.disabled
|
||||
? null
|
||||
: this.config.launchableAddress(this.info, this.pkg.hosts)
|
||||
get href() {
|
||||
return this.config.launchableAddress(this.info, this.pkg.hosts)
|
||||
}
|
||||
|
||||
openUI() {
|
||||
this.document.defaultView?.open(this.href, '_blank', 'noreferrer')
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,12 @@ import {
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TuiTable } from '@taiga-ui/addon-table'
|
||||
import { tuiDefaultSort } from '@taiga-ui/cdk'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { getAddresses } from '../../../components/interfaces/interface.utils'
|
||||
import { ServiceInterfaceComponent } from './interface.component'
|
||||
import { ServiceInterfaceItemComponent } from './interface-item.component'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
@@ -24,8 +23,8 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
<tr>
|
||||
<th tuiTh>{{ 'Name' | i18n }}</th>
|
||||
<th tuiTh>{{ 'Type' | i18n }}</th>
|
||||
<th tuiTh [style.text-align]="'center'">{{ 'Hosting' | i18n }}</th>
|
||||
<th tuiTh>{{ 'Description' | i18n }}</th>
|
||||
<th tuiTh>{{ 'Hosting' | i18n }}</th>
|
||||
<th tuiTh></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -33,7 +32,6 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
@for (info of interfaces(); track $index) {
|
||||
<tr
|
||||
serviceInterface
|
||||
[routerLink]="info.routerLink"
|
||||
[info]="info"
|
||||
[pkg]="pkg()"
|
||||
[disabled]="disabled()"
|
||||
@@ -44,12 +42,12 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
grid-column: span 4;
|
||||
grid-column: span 6;
|
||||
}
|
||||
`,
|
||||
host: { class: 'g-card' },
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ServiceInterfaceComponent, TuiTable, RouterLink, i18nPipe],
|
||||
imports: [ServiceInterfaceItemComponent, TuiTable, i18nPipe],
|
||||
})
|
||||
export class ServiceInterfacesComponent {
|
||||
private readonly config = inject(ConfigService)
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
`
|
||||
:host {
|
||||
grid-column: span 2;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -77,6 +78,10 @@ import {
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
:host {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
div {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content;
|
||||
|
||||
@@ -5,56 +5,84 @@ import {
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiAvatar } from '@taiga-ui/kit'
|
||||
import { TuiAvatar, TuiFade } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import { ActionService } from 'src/app/services/action.service'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'tr[actionRequest]',
|
||||
selector: 'tr[task]',
|
||||
template: `
|
||||
<td>
|
||||
<td tuiFade>
|
||||
<tui-avatar size="xs"><img [src]="pkg()?.icon" alt="" /></tui-avatar>
|
||||
<span>{{ title() }}</span>
|
||||
<span>{{ pkgTitle() }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (actionRequest().severity === 'critical') {
|
||||
{{ pkg()?.actions?.[task().actionId]?.name }}
|
||||
</td>
|
||||
<td>
|
||||
@if (task().severity === 'critical') {
|
||||
<strong [style.color]="'var(--tui-status-warning)'">
|
||||
{{ 'Required' | i18n }}
|
||||
</strong>
|
||||
} @else {
|
||||
} @else if (task().severity === 'important') {
|
||||
<strong [style.color]="'var(--tui-status-info)'">
|
||||
{{ 'Recommended' | i18n }}
|
||||
</strong>
|
||||
} @else {
|
||||
<strong>
|
||||
{{ 'Optional' | i18n }}
|
||||
</strong>
|
||||
}
|
||||
</td>
|
||||
<td
|
||||
[style.color]="'var(--tui-text-secondary)'"
|
||||
[style.grid-area]="'2 / span 2'"
|
||||
[style.grid-area]="'2 / span 4'"
|
||||
>
|
||||
{{ actionRequest().reason || ('No reason provided' | i18n) }}
|
||||
{{ task().reason || ('No reason provided' | i18n) }}
|
||||
</td>
|
||||
<td>
|
||||
<button tuiButton (click)="handle()">
|
||||
{{ pkg()?.actions?.[actionRequest().actionId]?.name }}
|
||||
</button>
|
||||
@if (task().severity !== 'critical') {
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.trash"
|
||||
appearance="flat-grayscale"
|
||||
(click)="dismiss()"
|
||||
></button>
|
||||
}
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.play"
|
||||
appearance="flat-grayscale"
|
||||
(click)="handle()"
|
||||
></button>
|
||||
</td>
|
||||
`,
|
||||
styles: `
|
||||
td:first-child {
|
||||
white-space: nowrap;
|
||||
max-width: 10rem;
|
||||
max-width: 15rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
grid-area: 3 / span 4;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
grid-area: span 2;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
@@ -64,32 +92,66 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
align-items: center;
|
||||
padding: 1rem 0.5rem;
|
||||
padding: 1rem 0rem 1rem 0.5rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
td {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, TuiAvatar, i18nPipe],
|
||||
imports: [TuiButton, TuiAvatar, i18nPipe, TuiFade],
|
||||
})
|
||||
export class ServiceTaskComponent {
|
||||
private readonly actionService = inject(ActionService)
|
||||
private readonly dialog = inject(DialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
|
||||
readonly actionRequest = input.required<T.Task>()
|
||||
readonly task = input.required<T.Task & { replayId: string }>()
|
||||
readonly services = input.required<Record<string, PackageDataEntry>>()
|
||||
|
||||
readonly pkg = computed(() => this.services()[this.actionRequest().packageId])
|
||||
readonly title = computed((pkg = this.pkg()) => pkg && getManifest(pkg).title)
|
||||
readonly pkg = computed(() => this.services()[this.task().packageId])
|
||||
readonly pkgTitle = computed(
|
||||
(pkg = this.pkg()) => pkg && getManifest(pkg).title,
|
||||
)
|
||||
|
||||
async dismiss() {
|
||||
this.dialog
|
||||
.openConfirm<boolean>({
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Are you sure you want to dismiss this task?',
|
||||
yes: 'Dismiss',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open().subscribe()
|
||||
try {
|
||||
await this.api.clearTask({
|
||||
packageId: this.task().packageId,
|
||||
replayId: this.task().replayId,
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async handle() {
|
||||
const title = this.title()
|
||||
const title = this.pkgTitle()
|
||||
const pkg = this.pkg()
|
||||
const metadata = pkg?.actions[this.actionRequest().actionId]
|
||||
const metadata = pkg?.actions[this.task().actionId]
|
||||
|
||||
if (!title || !pkg || !metadata) {
|
||||
return
|
||||
@@ -97,16 +159,16 @@ export class ServiceTaskComponent {
|
||||
|
||||
this.actionService.present({
|
||||
pkgInfo: {
|
||||
id: this.actionRequest().packageId,
|
||||
id: this.task().packageId,
|
||||
title,
|
||||
mainStatus: pkg.status.main,
|
||||
icon: pkg.icon,
|
||||
},
|
||||
actionInfo: {
|
||||
id: this.actionRequest().actionId,
|
||||
id: this.task().actionId,
|
||||
metadata,
|
||||
},
|
||||
requestInfo: this.actionRequest(),
|
||||
requestInfo: this.task(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,18 +19,19 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
<thead>
|
||||
<tr>
|
||||
<th tuiTh>{{ 'Service' | i18n }}</th>
|
||||
<th tuiTh>{{ 'Type' | i18n }}</th>
|
||||
<th tuiTh>{{ 'Action' }}</th>
|
||||
<th tuiTh>{{ 'Severity' }}</th>
|
||||
<th tuiTh>{{ 'Description' | i18n }}</th>
|
||||
<th tuiTh></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of requests(); track $index) {
|
||||
<tr [actionRequest]="item.task" [services]="services()"></tr>
|
||||
@for (item of tasks(); track $index) {
|
||||
<tr [task]="item.task" [services]="services()"></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (!requests().length) {
|
||||
@if (!tasks().length) {
|
||||
<app-placeholder icon="@tui.list-checks">
|
||||
{{ 'All tasks complete' | i18n }}
|
||||
</app-placeholder>
|
||||
@@ -50,8 +51,12 @@ export class ServiceTasksComponent {
|
||||
readonly pkg = input.required<PackageDataEntry>()
|
||||
readonly services = input.required<Record<string, PackageDataEntry>>()
|
||||
|
||||
readonly requests = computed(() =>
|
||||
Object.values(this.pkg().tasks)
|
||||
readonly tasks = computed(() =>
|
||||
Object.entries(this.pkg().tasks)
|
||||
.map(([replayId, entry]) => ({
|
||||
...entry,
|
||||
task: { ...entry.task, replayId },
|
||||
}))
|
||||
.filter(
|
||||
t =>
|
||||
this.services()[t.task.packageId]?.actions[t.task.actionId] &&
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { map, timer } from 'rxjs'
|
||||
import { distinctUntilChanged } from 'rxjs/operators'
|
||||
|
||||
@Component({
|
||||
selector: 'service-uptime',
|
||||
template: `
|
||||
<header>{{ 'Uptime' | i18n }}</header>
|
||||
<section>
|
||||
@if (uptime$ | async; as time) {
|
||||
<div>
|
||||
<label>{{ time.days }}</label>
|
||||
{{ 'Days' | i18n }}
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ time.hours }}</label>
|
||||
{{ 'Hours' | i18n }}
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ time.minutes }}</label>
|
||||
{{ 'Minutes' | i18n }}
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ time.seconds }}</label>
|
||||
{{ 'Seconds' | i18n }}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--tui-font-heading-4);
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
section {
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
place-content: center;
|
||||
margin: auto;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
color: var(--tui-text-secondary);
|
||||
font: var(--tui-font-text-ui-xs);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: min(6vw, 2.5rem);
|
||||
margin: 1rem 0;
|
||||
color: var(--tui-text-primary);
|
||||
}
|
||||
`,
|
||||
],
|
||||
host: { class: 'g-card' },
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [i18nPipe, AsyncPipe],
|
||||
})
|
||||
export class ServiceUptimeComponent {
|
||||
protected readonly uptime$ = timer(0, 1000).pipe(
|
||||
map(() =>
|
||||
this.started()
|
||||
? Math.max(Date.now() - new Date(this.started()).getTime(), 0)
|
||||
: 0,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
map(delta => ({
|
||||
seconds: Math.floor(delta / 1000) % 60,
|
||||
minutes: Math.floor(delta / (1000 * 60)) % 60,
|
||||
hours: Math.floor(delta / (1000 * 60 * 60)) % 24,
|
||||
days: Math.floor(delta / (1000 * 60 * 60 * 24)),
|
||||
})),
|
||||
)
|
||||
|
||||
readonly started = input('')
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { DepErrorService } from 'src/app/services/dep-error.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
import { UILaunchComponent } from './ui.component'
|
||||
import { UILaunchComponent } from './ui-launch.component'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
|
||||
const RUNNING = ['running', 'starting', 'restarting']
|
||||
@@ -23,6 +23,7 @@ const RUNNING = ['running', 'starting', 'restarting']
|
||||
standalone: true,
|
||||
selector: 'fieldset[appControls]',
|
||||
template: `
|
||||
<app-ui-launch [pkg]="pkg()" />
|
||||
@if (running()) {
|
||||
<button
|
||||
tuiIconButton
|
||||
@@ -42,8 +43,6 @@ const RUNNING = ['running', 'starting', 'restarting']
|
||||
{{ 'Start' | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<app-ui-launch [pkg]="pkg()" />
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, UILaunchComponent, TuiLet, AsyncPipe, i18nPipe],
|
||||
@@ -53,6 +52,7 @@ const RUNNING = ['running', 'starting', 'restarting']
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: default;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
|
||||
@@ -35,9 +35,7 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
<th tuiTh [requiredSort]="true" [sorter]="status">
|
||||
{{ 'Status' | i18n }}
|
||||
</th>
|
||||
<th [style.width.rem]="8" [style.text-indent.rem]="1.5">
|
||||
{{ 'Controls' | i18n }}
|
||||
</th>
|
||||
<th [style.width.rem]="8" [style.text-indent.rem]="1.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -57,7 +57,8 @@ export class StatusComponent {
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
get healthy(): boolean {
|
||||
return !this.hasDepErrors && this.getStatus(this.pkg).health !== 'failure'
|
||||
const { primary, health } = this.getStatus(this.pkg)
|
||||
return !this.hasDepErrors && primary !== 'error' && health !== 'failure'
|
||||
}
|
||||
|
||||
get loading(): boolean {
|
||||
@@ -66,7 +67,7 @@ export class StatusComponent {
|
||||
|
||||
@tuiPure
|
||||
getStatus(pkg: PackageDataEntry) {
|
||||
return renderPkgStatus(pkg, {})
|
||||
return renderPkgStatus(pkg)
|
||||
}
|
||||
|
||||
get status(): i18nKey {
|
||||
@@ -95,6 +96,8 @@ export class StatusComponent {
|
||||
return 'Removing'
|
||||
case 'restoring':
|
||||
return 'Restoring'
|
||||
case 'error':
|
||||
return 'Error'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
@@ -120,6 +123,8 @@ export class StatusComponent {
|
||||
return 'var(--tui-status-positive)'
|
||||
case 'actionRequired':
|
||||
return 'var(--tui-status-warning)'
|
||||
case 'error':
|
||||
return 'var(--tui-status-negative)'
|
||||
case 'installing':
|
||||
case 'updating':
|
||||
case 'stopping':
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -23,7 +24,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
[disabled]="!isRunning"
|
||||
[tuiDropdown]="content"
|
||||
>
|
||||
{{ 'Launch UI' | i18n }}
|
||||
{{ 'Open' | i18n }}
|
||||
</button>
|
||||
<ng-template #content>
|
||||
<tui-data-list>
|
||||
@@ -39,16 +40,15 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
}
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
} @else {
|
||||
<a
|
||||
} @else if (interfaces[0]) {
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.external-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[attr.href]="getHref(first)"
|
||||
[disabled]="!isRunning"
|
||||
(click)="openUI(interfaces[0])"
|
||||
>
|
||||
{{ first?.name }}
|
||||
</a>
|
||||
{{ interfaces[0].name }}
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@@ -61,6 +61,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
})
|
||||
export class UILaunchComponent {
|
||||
private readonly config = inject(ConfigService)
|
||||
private readonly document = inject(DOCUMENT)
|
||||
|
||||
@Input()
|
||||
pkg!: PackageDataEntry
|
||||
@@ -73,10 +74,6 @@ export class UILaunchComponent {
|
||||
return this.pkg.status.main === 'running'
|
||||
}
|
||||
|
||||
get first(): T.ServiceInterface | undefined {
|
||||
return this.interfaces[0]
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
getInterfaces(pkg?: PackageDataEntry): T.ServiceInterface[] {
|
||||
return pkg
|
||||
@@ -89,9 +86,11 @@ export class UILaunchComponent {
|
||||
: []
|
||||
}
|
||||
|
||||
getHref(ui?: T.ServiceInterface): string | null {
|
||||
return ui && this.isRunning
|
||||
? this.config.launchableAddress(ui, this.pkg.hosts)
|
||||
: null
|
||||
getHref(ui: T.ServiceInterface): string {
|
||||
return this.config.launchableAddress(ui, this.pkg.hosts)
|
||||
}
|
||||
|
||||
openUI(ui: T.ServiceInterface) {
|
||||
this.document.defaultView?.open(this.getHref(ui), '_blank', 'noreferrer')
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
standalone: true,
|
||||
selector: 'app-action-success-single',
|
||||
template: `
|
||||
<p class="qr"><ng-container *ngTemplateOutlet="qr" /></p>
|
||||
@if (single.qr) {
|
||||
<p class="qr"><ng-container *ngTemplateOutlet="qr" /></p>
|
||||
}
|
||||
<tui-input
|
||||
[readOnly]="true"
|
||||
[ngModel]="single.value"
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { getPkgId, i18nPipe } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiItem } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiLink } from '@taiga-ui/core'
|
||||
import { TuiBreadcrumbs } from '@taiga-ui/kit'
|
||||
import { TuiBadge, TuiBreadcrumbs } from '@taiga-ui/kit'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
|
||||
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
|
||||
@@ -26,21 +28,34 @@ import { TitleDirective } from 'src/app/services/title.service'
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
{{ interface()?.name }}
|
||||
<interface-status [public]="!!interface()?.public" />
|
||||
<interface-status
|
||||
[style.margin-left.rem]="0.5"
|
||||
[public]="!!interface()?.public"
|
||||
/>
|
||||
</ng-container>
|
||||
<tui-breadcrumbs size="l" [style.margin-block-end.rem]="1">
|
||||
<tui-breadcrumbs size="l">
|
||||
<a *tuiItem tuiLink appearance="action-grayscale" routerLink="../..">
|
||||
{{ 'Dashboard' | i18n }}
|
||||
</a>
|
||||
<span *tuiItem class="g-primary">
|
||||
{{ interface()?.name }}
|
||||
<interface-status [public]="!!interface()?.public" />
|
||||
</span>
|
||||
<span *tuiItem class="g-primary">{{ interface()?.name }}</span>
|
||||
</tui-breadcrumbs>
|
||||
@if (interface(); as serviceInterface) {
|
||||
@if (interface(); as value) {
|
||||
<header tuiHeader [style.margin-bottom.rem]="1">
|
||||
<hgroup>
|
||||
<h3>
|
||||
{{ value.name }}
|
||||
<tui-badge size="l" [appearance]="getAppearance(value.type)">
|
||||
{{ value.type }}
|
||||
</tui-badge>
|
||||
<interface-status [public]="value.public" />
|
||||
</h3>
|
||||
<p tuiSubtitle>{{ value.description }}</p>
|
||||
</hgroup>
|
||||
</header>
|
||||
<app-interface
|
||||
[packageId]="pkgId"
|
||||
[serviceInterface]="serviceInterface"
|
||||
[value]="value"
|
||||
[isRunning]="isRunning()"
|
||||
/>
|
||||
}
|
||||
`,
|
||||
@@ -48,6 +63,19 @@ import { TitleDirective } from 'src/app/services/title.service'
|
||||
:host-context(tui-root._mobile) tui-breadcrumbs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
font-size: 2.4rem;
|
||||
|
||||
tui-badge {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
`,
|
||||
host: { class: 'g-subpage' },
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -62,6 +90,8 @@ import { TitleDirective } from 'src/app/services/title.service'
|
||||
TuiLink,
|
||||
InterfaceStatusComponent,
|
||||
i18nPipe,
|
||||
TuiBadge,
|
||||
TuiHeader,
|
||||
],
|
||||
})
|
||||
export default class ServiceInterfaceRoute {
|
||||
@@ -74,6 +104,10 @@ export default class ServiceInterfaceRoute {
|
||||
inject<PatchDB<DataModel>>(PatchDB).watch$('packageData', this.pkgId),
|
||||
)
|
||||
|
||||
readonly isRunning = computed(() => {
|
||||
return this.pkg()?.status.main === 'running'
|
||||
})
|
||||
|
||||
readonly interface = computed(() => {
|
||||
const pkg = this.pkg()
|
||||
const id = this.interfaceId()
|
||||
@@ -99,4 +133,15 @@ export default class ServiceInterfaceRoute {
|
||||
addresses: getAddresses(item, host, this.config),
|
||||
}
|
||||
})
|
||||
|
||||
getAppearance(type: T.ServiceInterfaceType = 'ui'): string {
|
||||
switch (type) {
|
||||
case 'ui':
|
||||
return 'positive'
|
||||
case 'api':
|
||||
return 'info'
|
||||
case 'p2p':
|
||||
return 'negative'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,21 @@ import { TuiCell } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
PrimaryStatus,
|
||||
renderPkgStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
|
||||
const INACTIVE: PrimaryStatus[] = [
|
||||
'installing',
|
||||
'updating',
|
||||
'removing',
|
||||
'restoring',
|
||||
'backingUp',
|
||||
]
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (service()) {
|
||||
@@ -34,7 +46,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
<span tuiSubtitle>{{ manifest()?.version }}</span>
|
||||
</span>
|
||||
</header>
|
||||
<nav>
|
||||
<nav [attr.inert]="isInactive() ? '' : null">
|
||||
@for (item of nav; track $index) {
|
||||
<a
|
||||
tuiCell
|
||||
@@ -76,6 +88,10 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
margin: 0 -0.5rem;
|
||||
}
|
||||
|
||||
nav[inert] a:not(:first-child) {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
}
|
||||
|
||||
a a {
|
||||
display: none;
|
||||
}
|
||||
@@ -178,4 +194,9 @@ export class ServiceOutletComponent {
|
||||
protected readonly manifest = computed(
|
||||
(pkg = this.service()) => pkg && getManifest(pkg),
|
||||
)
|
||||
|
||||
protected readonly isInactive = computed(
|
||||
(pkg = this.service()) =>
|
||||
!pkg || INACTIVE.includes(renderPkgStatus(pkg).primary),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { isEmptyObject } from '@start9labs/shared'
|
||||
import { WaIntersectionObserver } from '@ng-web-apis/intersection-observer'
|
||||
import { i18nPipe, isEmptyObject } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiElement } from '@taiga-ui/cdk'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map, of } from 'rxjs'
|
||||
import { UptimeComponent } from 'src/app/routes/portal/components/uptime.component'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { DepErrorService } from 'src/app/services/dep-error.service'
|
||||
import {
|
||||
@@ -27,14 +29,14 @@ import { ServiceHealthChecksComponent } from '../components/health-checks.compon
|
||||
import { ServiceInterfacesComponent } from '../components/interfaces.component'
|
||||
import { ServiceInstallProgressComponent } from '../components/progress.component'
|
||||
import { ServiceStatusComponent } from '../components/status.component'
|
||||
import { ServiceUptimeComponent } from '../components/uptime.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (pkg(); as pkg) {
|
||||
@if (pkg.status.main === 'error') {
|
||||
<service-error [pkg]="pkg" />
|
||||
}
|
||||
@if (installing()) {
|
||||
} @else if (installing()) {
|
||||
<service-install-progress [pkg]="pkg" />
|
||||
} @else if (installed()) {
|
||||
<service-status
|
||||
@@ -42,16 +44,13 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
[installingInfo]="pkg.stateInfo.installingInfo"
|
||||
[status]="status()"
|
||||
>
|
||||
@if ($any(pkg.status)?.started; as started) {
|
||||
<p class="g-secondary" [appUptime]="started"></p>
|
||||
}
|
||||
|
||||
@if (connected()) {
|
||||
<service-controls [pkg]="pkg" [status]="status()" />
|
||||
}
|
||||
</service-status>
|
||||
|
||||
@if (status() !== 'backingUp') {
|
||||
<service-uptime [started]="$any(pkg.status)?.started" />
|
||||
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
|
||||
|
||||
@if (errors() | async; as errors) {
|
||||
@@ -63,7 +62,30 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
}
|
||||
|
||||
<service-health-checks [checks]="health()" />
|
||||
<service-tasks [pkg]="pkg" [services]="services() || {}" />
|
||||
<service-tasks
|
||||
#tasks="elementRef"
|
||||
tuiElement
|
||||
waIntersectionObserver
|
||||
waIntersectionThreshold="0.5"
|
||||
(waIntersectionObservee)="scrolled = $event.at(-1)?.isIntersecting"
|
||||
[pkg]="pkg"
|
||||
[services]="services() || {}"
|
||||
/>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.arrow-down"
|
||||
tabindex="-1"
|
||||
class="arrow"
|
||||
[class.arrow_hidden]="scrolled"
|
||||
(click)="
|
||||
tasks.nativeElement.scrollIntoView({
|
||||
block: 'end',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ 'Tasks' | i18n }}
|
||||
</button>
|
||||
}
|
||||
} @else if (removing()) {
|
||||
<service-status
|
||||
@@ -74,6 +96,14 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
|
||||
|
||||
@keyframes bounce {
|
||||
to {
|
||||
transform: translateY(-1rem);
|
||||
}
|
||||
}
|
||||
|
||||
:host {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
@@ -86,6 +116,23 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
@include taiga.transition(opacity);
|
||||
position: sticky;
|
||||
bottom: 1rem;
|
||||
border-radius: 100%;
|
||||
place-self: center;
|
||||
grid-area: auto / span 6;
|
||||
box-shadow: inset 0 0 0 2rem var(--tui-status-warning);
|
||||
animation: bounce 1s infinite alternate;
|
||||
|
||||
&_hidden,
|
||||
:host:has(::ng-deep service-tasks app-placeholder) & {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -99,6 +146,10 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiElement,
|
||||
TuiButton,
|
||||
WaIntersectionObserver,
|
||||
i18nPipe,
|
||||
ServiceInstallProgressComponent,
|
||||
ServiceStatusComponent,
|
||||
ServiceControlsComponent,
|
||||
@@ -107,13 +158,15 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
ServiceDependenciesComponent,
|
||||
ServiceErrorComponent,
|
||||
ServiceTasksComponent,
|
||||
UptimeComponent,
|
||||
ServiceUptimeComponent,
|
||||
],
|
||||
})
|
||||
export class ServiceRoute {
|
||||
private readonly errorService = inject(DepErrorService)
|
||||
protected readonly connected = toSignal(inject(ConnectionService))
|
||||
|
||||
protected scrolled?: boolean
|
||||
|
||||
protected readonly id = toSignal(
|
||||
inject(ActivatedRoute).paramMap.pipe(map(params => params.get('pkgId'))),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
@@ -89,6 +90,10 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
{{ 'Edit' | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<app-placeholder icon="@tui.shield-question">
|
||||
{{ 'No saved providers' | i18n }}
|
||||
</app-placeholder>
|
||||
}
|
||||
} @else {
|
||||
<tui-loader [style.height.rem]="5" />
|
||||
@@ -113,6 +118,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
TitleDirective,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
PlaceholderComponent,
|
||||
],
|
||||
})
|
||||
export default class SystemAcmeComponent {
|
||||
|
||||
@@ -75,7 +75,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
<button
|
||||
tuiButton
|
||||
size="l"
|
||||
[disabled]="form.invalid"
|
||||
[disabled]="form.invalid || form.pristine"
|
||||
(click)="save(form.value)"
|
||||
>
|
||||
{{ 'Save' | i18n }}
|
||||
@@ -98,7 +98,6 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
size="l"
|
||||
[disabled]="!testAddress || form.invalid"
|
||||
(click)="sendTestEmail(form.value)"
|
||||
@@ -188,11 +187,14 @@ export default class SystemEmailComponent {
|
||||
async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) {
|
||||
const loader = this.loader.open('Sending email').subscribe()
|
||||
const success =
|
||||
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}.<br /><br /><b>${this.i18n.transform('Check your spam folder and mark as not spam.')}</b>` as i18nKey
|
||||
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
|
||||
|
||||
try {
|
||||
await this.api.testSmtp({ to: this.testAddress, ...value })
|
||||
this.dialog.openAlert(success, { label: 'Success' }).subscribe()
|
||||
this.dialog
|
||||
.openAlert(success, { label: 'Success', size: 's' })
|
||||
.subscribe()
|
||||
this.testAddress = ''
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Title } from '@angular/platform-browser'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
DialogService,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiBadge,
|
||||
TuiButtonLoading,
|
||||
TuiButtonSelect,
|
||||
TuiDataListWrapper,
|
||||
@@ -92,7 +94,9 @@ import { SystemWipeComponent } from './wipe.component'
|
||||
<tui-icon icon="@tui.app-window" />
|
||||
<span tuiTitle>
|
||||
<strong>{{ 'Browser Tab Title' | i18n }}</strong>
|
||||
<span tuiSubtitle>{{ name() }}</span>
|
||||
<span tuiSubtitle>
|
||||
{{ 'Customize the name appearing in your browser tab' | i18n }}
|
||||
</span>
|
||||
</span>
|
||||
<button tuiButton (click)="onTitle()">{{ 'Change' | i18n }}</button>
|
||||
</div>
|
||||
@@ -134,6 +138,43 @@ import { SystemWipeComponent } from './wipe.component'
|
||||
{{ 'Download' | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
<div tuiCell tuiAppearance="outline-grayscale">
|
||||
<tui-icon icon="@tui.monitor" />
|
||||
<span tuiTitle>
|
||||
<strong>
|
||||
{{ 'Kiosk Mode' | i18n }}
|
||||
<tui-badge
|
||||
size="m"
|
||||
[appearance]="
|
||||
server.kiosk ? 'primary-success' : 'primary-destructive'
|
||||
"
|
||||
>
|
||||
{{ server.kiosk ? ('Enabled' | i18n) : ('Disabled' | i18n) }}
|
||||
</tui-badge>
|
||||
</strong>
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
server.kiosk === true
|
||||
? ('Disable Kiosk Mode unless you need to attach a monitor'
|
||||
| i18n)
|
||||
: server.kiosk === false
|
||||
? ('Enable Kiosk Mode if you need to attach a monitor' | i18n)
|
||||
: ('Kiosk Mode is unavailable on this device' | i18n)
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
@if (server.kiosk !== null) {
|
||||
<button
|
||||
tuiButton
|
||||
[appearance]="
|
||||
server.kiosk ? 'primary-destructive' : 'primary-success'
|
||||
"
|
||||
(click)="tryToggleKiosk()"
|
||||
>
|
||||
{{ server.kiosk ? ('Disable' | i18n) : ('Enable' | i18n) }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div tuiCell tuiAppearance="outline-grayscale">
|
||||
<tui-icon icon="@tui.circle-power" (click)="count = count + 1" />
|
||||
<span tuiTitle>
|
||||
@@ -190,11 +231,6 @@ import { SystemWipeComponent } from './wipe.component'
|
||||
[tuiCell] {
|
||||
background: var(--tui-background-neutral-1);
|
||||
}
|
||||
|
||||
[tuiSubtitle],
|
||||
tui-data-list-wrapper ::ng-deep [tuiOption] {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
`,
|
||||
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -217,9 +253,11 @@ import { SystemWipeComponent } from './wipe.component'
|
||||
TuiTextfield,
|
||||
FormsModule,
|
||||
SnekDirective,
|
||||
TuiBadge,
|
||||
],
|
||||
})
|
||||
export default class SystemGeneralComponent {
|
||||
private readonly title = inject(Title)
|
||||
private readonly dialogs = inject(TuiResponsiveDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
@@ -276,9 +314,11 @@ export default class SystemGeneralComponent {
|
||||
})
|
||||
.subscribe(async name => {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
const title = `${name || 'StartOS'} — ${this.i18n.transform('System')}`
|
||||
|
||||
try {
|
||||
await this.api.setDbValue(['name'], name || null)
|
||||
this.title.setTitle(title)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
@@ -310,6 +350,28 @@ export default class SystemGeneralComponent {
|
||||
this.document.getElementById('download-ca')?.click()
|
||||
}
|
||||
|
||||
async tryToggleKiosk() {
|
||||
if (
|
||||
this.server()?.kiosk &&
|
||||
['localhost', '127.0.0.1'].includes(this.document.location.hostname)
|
||||
) {
|
||||
return this.dialog
|
||||
.openConfirm({
|
||||
label: 'Warning',
|
||||
data: {
|
||||
content:
|
||||
'You are currently using a kiosk. Disabling Kiosk Mode will result in the kiosk disconnecting.',
|
||||
yes: 'Disable',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => this.toggleKiosk())
|
||||
}
|
||||
|
||||
this.toggleKiosk()
|
||||
}
|
||||
|
||||
async onRepair() {
|
||||
this.dialog
|
||||
.openConfirm({
|
||||
@@ -332,6 +394,22 @@ export default class SystemGeneralComponent {
|
||||
})
|
||||
}
|
||||
|
||||
private async toggleKiosk() {
|
||||
const kiosk = this.server()?.kiosk
|
||||
|
||||
const loader = this.loader
|
||||
.open(kiosk ? 'Disabling' : 'Enabling')
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
await this.api.toggleKiosk(!kiosk)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async resetTor(wipeState: boolean) {
|
||||
const loader = this.loader.open('Resetting Tor').subscribe()
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { firstValueFrom } from 'rxjs'
|
||||
template: `
|
||||
<h2 style="margin-top: 0">StartOS {{ versions[0]?.version }}</h2>
|
||||
<h3 style="color: var(--tui-text-secondary); font-weight: normal">
|
||||
{{ 'Release Notes' | i18n }}
|
||||
{{ 'Release notes' | i18n }}
|
||||
</h3>
|
||||
<tui-scrollbar style="margin-bottom: 24px; max-height: 50vh;">
|
||||
@for (v of versions; track $index) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user