diff --git a/Cargo.toml b/Cargo.toml index 56df0e2c..bff920a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,7 +123,6 @@ tempfile = { version = "3.10" } dunce = { version = "1.0.4" } serde_json = { version = "1.0" } serde_ini = { version = "0.2" } -flate2 = { version = "1.0" } tar = { version = "0.4" } zip = { version = "0.6" } thiserror = { version = "1.0" } @@ -142,6 +141,11 @@ indicatif = { version = "0.17" } futures = { version = "0.3" } async-recursion = { version = "1.1" } async_zip = { version = "0.0.17", features = [ "full" ] } +async-compression = { version = "0.4.12", features = [ + "flate2", + "tokio" +] } +flate2 = { version = "1.0" } regex = { version = "1.10" } sysinfo = { version = "0.30" } sys-info = { version = "0.9" } diff --git a/apps/desktop/src/api/commands.rs b/apps/desktop/src/api/commands.rs index fba2a9e9..73e25a68 100644 --- a/apps/desktop/src/api/commands.rs +++ b/apps/desktop/src/api/commands.rs @@ -33,6 +33,7 @@ macro_rules! collect_commands { run_cluster, get_cluster_logs, get_cluster_log, + upload_log, // Processor get_running_clusters, get_processes_by_path, @@ -194,6 +195,14 @@ pub async fn get_cluster_log(uuid: Uuid, log_name: String) -> Result Result { + let cluster = cluster::get_by_uuid(uuid, None).await?.ok_or("cluster not found")?; + let id = cluster::upload_log(&cluster.cluster_path(), log_name).await?; + Ok(id) +} + #[specta::specta] #[tauri::command] pub async fn get_clusters() -> Result, String> { diff --git a/apps/frontend/src/ui/components/base/Button.module.scss b/apps/frontend/src/ui/components/base/Button.module.scss index 164c42de..7344d5da 100644 --- a/apps/frontend/src/ui/components/base/Button.module.scss +++ b/apps/frontend/src/ui/components/base/Button.module.scss @@ -2,7 +2,7 @@ @apply py-1.5 px-3 rounded-lg flex flex-row justify-center items-center gap-x-2.5; &:disabled { - @apply bg-opacity-10! text-op-10! pointer-events-none; + @apply border-op-1! bg-opacity-10! text-op-10! pointer-events-none; svg { @apply fill-op-10! stroke-op-10!; diff --git a/apps/frontend/src/ui/components/base/Dropdown.module.scss b/apps/frontend/src/ui/components/base/Dropdown.module.scss index f5358e2e..c40e8028 100644 --- a/apps/frontend/src/ui/components/base/Dropdown.module.scss +++ b/apps/frontend/src/ui/components/base/Dropdown.module.scss @@ -2,7 +2,7 @@ @apply relative h-full; &[data-disabled="true"] { - @apply pointer-events-none opacity-50; + @apply pointer-events-none opacity-10; } .dropdown_elements_container { diff --git a/apps/frontend/src/ui/components/content/FormattedLog.module.scss b/apps/frontend/src/ui/components/content/FormattedLog.module.scss new file mode 100644 index 00000000..ad6a3c6f --- /dev/null +++ b/apps/frontend/src/ui/components/content/FormattedLog.module.scss @@ -0,0 +1,51 @@ +.log { + @apply whitespace-pre absolute flex flex-col text-md; + * { + @apply whitespace-pre! select-text!; + } + + > span { + @apply rounded-md hover:bg-component-bg-hover p-1.5; + + &[data-empty="true"] { + @apply hover:bg-none h-6.5; + } + + &[data-level="INFO"] { + @apply text-code-info; + } + + &[data-level="DEBUG"] { + @apply text-code-debug; + } + + &[data-level="TRACE"] { + @apply text-code-trace; + } + + &[data-level="WARN"] { + @apply text-code-warn; + } + + &[data-level="ERROR"] { + @apply text-code-error; + } + + > span:nth-child(1) { + @apply relative text-transparent text-op-0!; + } + + > span:nth-child(1):before { + content: attr(data-date); + @apply absolute w-full text-center z-1 select-text! text-fg-secondary text-op-100!; + } + + > span:nth-child(2) { + @apply font-bold; + } + + &[data-chat="true"] > span:nth-child(3) { + @apply text-white; + } + } +} diff --git a/apps/frontend/src/ui/components/content/FormattedLog.tsx b/apps/frontend/src/ui/components/content/FormattedLog.tsx new file mode 100644 index 00000000..ea251d23 --- /dev/null +++ b/apps/frontend/src/ui/components/content/FormattedLog.tsx @@ -0,0 +1,79 @@ +import { For, createMemo } from 'solid-js'; +import styles from './FormattedLog.module.scss'; + +interface FormattedLogProps { + log: string; +}; + +function FormattedLog(props: FormattedLogProps) { + const lines = createMemo(() => props.log.split('\n')); + + return ( + + + {line => } + + + ); +} + +interface LineProps { + line: string; +}; + +/** + * The first part of the pattern = `\[(\d+:\d+:\d+)\]`, attempts to find + * and group `[DD:DD:DD]` where DD is a digit. + * It matches only the formatted time (without the brackets) + * + * The second part of the pattern = `\[.[^\n\r/\u2028\u2029]*\/(.+)\]`, + * attempts to find and group `[Thread name/LEVEL]` + * where LEVEL is commonly either `INFO`, `WARN`, `ERROR`. + * It only matches the Thread name and LEVEL + * + * The third part of the pattern = `: (?:\[(CHAT)\])?`, + * attempts to find and group `: [CHAT]`. + * It only matches `CHAT` if it is present. + * This is an **optional** capture group. + */ +const REGEX_PATTERN = /\[(\d+:\d+:\d+)\] \[(.[^(\n\r/\u2028\u2029]*)\/(\w+)\]: (?:\[(CHAT)\])?/; + +function Line(props: LineProps) { + const format = (line: string) => { + const prefix = line.match(REGEX_PATTERN); + + const isEmpty = line.trim() === ''; + + if (isEmpty) + line = '\n'; + + if (prefix === null) + return ( + + {line} + + ); + + const isChatMsg = prefix[4] === 'CHAT'; + + return ( + + {`[${prefix[1]}]`} + {` [${prefix[2]}/${prefix[3]}]: ${isChatMsg ? `[${prefix[4]}]` : ''}`} + {line.slice(prefix[0].length)} + + ); + }; + + return ( + <>{format(props.line)} + ); +} + +export default FormattedLog; diff --git a/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx b/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx index d2e6a115..eafdedcb 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx @@ -1,6 +1,6 @@ import { For, Show, createEffect, createSignal, untrack } from 'solid-js'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid'; -import { LinkExternal01Icon } from '@untitled-theme/icons-solid'; +import { LinkExternal01Icon, Upload01Icon } from '@untitled-theme/icons-solid'; import { open } from '@tauri-apps/plugin-shell'; import useCommand, { tryResult } from '~ui/hooks/useCommand'; import { bridge } from '~imports'; @@ -9,6 +9,7 @@ import Dropdown from '~ui/components/base/Dropdown'; import Button from '~ui/components/base/Button'; import useSettingsContext from '~ui/hooks/useSettings'; import joinPath from '~utils/helpers'; +import FormattedLog from '~ui/components/content/FormattedLog'; function ClusterLogs() { const [cluster] = useClusterContext(); @@ -16,10 +17,10 @@ function ClusterLogs() { const settings = useSettingsContext(); const [activeLogFile, setActiveLogFile] = createSignal(null); - const [log, setLog] = createSignal(null); + const [logContent, setLogContent] = createSignal(null); function getAndSetLog(log_name: string) { - tryResult(bridge.commands.getClusterLog, cluster()!.uuid, log_name).then(setLog); + tryResult(bridge.commands.getClusterLog, cluster()!.uuid, log_name).then(setLogContent); } function changeLog(index: number) { @@ -41,6 +42,15 @@ function ClusterLogs() { open(joinPath(dir, 'logs')); } + async function uploadAndOpenLog() { + const log = activeLogFile(); + if (log === null) + return; + + const id = await tryResult(bridge.commands.uploadLog, cluster()!.uuid, log); + open(`https://mclo.gs/${id}`); + } + const missingLogs = () => logs() === undefined || logs()?.length === 0 || false; createEffect(() => { @@ -61,6 +71,14 @@ function ClusterLogs() {

Logs

+
- No logs were found.}> + No logs were found.}>
- {log()} +
diff --git a/apps/frontend/uno.config.ts b/apps/frontend/uno.config.ts index 76e2cb3c..63eb3dea 100644 --- a/apps/frontend/uno.config.ts +++ b/apps/frontend/uno.config.ts @@ -99,6 +99,14 @@ export default defineConfig({ }, }, + code: { + info: '#61AFEF', + warn: '#E5C07B', + error: '#BE5046', + debug: '#2B4BFF', + trace: '#FDFDFD', + }, + primary: '#11171C', secondary: '#192026', }, diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 3eb6d94c..d7a27a78 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -51,7 +51,6 @@ tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_ini = { workspace = true } -flate2 = { workspace = true } paste = { workspace = true } futures = { workspace = true } tar = { workspace = true } @@ -82,6 +81,8 @@ rand = { workspace = true } p256 = { workspace = true } byteorder = { workspace = true } base64 = { workspace = true } +async-compression = { workspace = true } +flate2 = { workspace = true } [target.'cfg(windows)'.dependencies] winreg = { workspace = true } diff --git a/packages/core/src/api/cluster/mod.rs b/packages/core/src/api/cluster/mod.rs index 01ed2986..76870465 100644 --- a/packages/core/src/api/cluster/mod.rs +++ b/packages/core/src/api/cluster/mod.rs @@ -13,6 +13,7 @@ use crate::prelude::{ClusterPath, JavaVersion, PackagePath}; use crate::store::MinecraftCredentials; pub use crate::store::{Cluster, JavaOptions, State}; +use crate::utils::http; use crate::utils::io::{self, IOError}; use std::collections::HashMap; @@ -266,10 +267,64 @@ pub async fn get_logs(path: &ClusterPath) -> crate::Result> { #[tracing::instrument] pub async fn get_log(path: &ClusterPath, log_name: String) -> crate::Result { let log_path = path.full_path().await?.join("logs").join(log_name); + + if !log_path.exists() { + return Err(anyhow::anyhow!( + "failed to get log at path {}", + log_path.display() + ) + .into()); + } + + if let Some(ext) = log_path.extension() { + if ext == "gz" { + let log = io::read_gz_to_string(&log_path).await?; + return Ok(log); + } + } + let log = io::read_to_string(&log_path).await?; Ok(log) } +/// Upload a log to https://mclo.gs/. If successful, it returns the log id +#[tracing::instrument] +pub async fn upload_log(path: &ClusterPath, log_name: String) -> crate::Result { + let log = get_log(path, log_name).await?; + + let mut form = HashMap::new(); + form.insert("content", log); + + #[derive(serde::Deserialize)] + struct ResponseBody { + pub success: bool, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub error: Option + } + + let parsed = serde_json::from_slice::( + &http::REQWEST_CLIENT.post(format!("{}/log", crate::constants::MCLOGS_API_URL)) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(&form) + .send() + .await? + .bytes() + .await? + )?; + + if parsed.success { + if let Some(id) = parsed.id { + Ok(id) + } else { + Err(anyhow::anyhow!("failed to get log id from mclo.gs").into()) + } + } else { + Err(anyhow::anyhow!("failed to upload log to mclo.gs: {}", parsed.error.unwrap_or_default()).into()) + } +} + /// edit a cluster with an async closure and it's [`ClusterPath`] pub async fn edit( path: &ClusterPath, diff --git a/packages/core/src/constants.rs b/packages/core/src/constants.rs index 1234209b..d8e1682e 100644 --- a/packages/core/src/constants.rs +++ b/packages/core/src/constants.rs @@ -37,6 +37,8 @@ pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2"; pub const CURSEFORGE_API_URL: &str = "https://api.curseforge"; /// Our metadata API base url. pub const METADATA_API_URL: &str = "https://meta.polyfrost.org"; +/// https://mclo.gs/ API base url. +pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; // =========== Paths =========== /// The public `settings.json` file used to store the global [`Settings`] state. diff --git a/packages/core/src/utils/io.rs b/packages/core/src/utils/io.rs index 3a7ce889..f40e9caf 100644 --- a/packages/core/src/utils/io.rs +++ b/packages/core/src/utils/io.rs @@ -3,8 +3,9 @@ //! Async wrapper around [`tokio::fs`] and [`std::io::Error`] for our error system. use std::io::Write; +use async_compression::tokio::bufread::GzipDecoder; use tempfile::NamedTempFile; -use tokio::task::spawn_blocking; +use tokio::{io::AsyncReadExt, task::spawn_blocking}; /// A wrapper around generic and unhelpful [`std::io::Error`] messages. #[derive(Debug, thiserror::Error)] @@ -85,6 +86,19 @@ pub async fn remove_dir_all(path: impl AsRef) -> Result<(), IOE }) } +/// Creates a future which will open a gzip compressed file for reading and read the entire contents into a string and return said string. +pub async fn read_gz_to_string(path: impl AsRef) -> Result { + let mut f = tokio::fs::File::open(path).await?; + let mut buf = vec![]; + f.read_to_end(&mut buf).await?; + + let mut decoder = GzipDecoder::new(buf.as_slice()); + let mut dst = String::new(); + decoder.read_to_string(&mut dst).await?; + + Ok(dst) +} + /// Creates a future which will open a file for reading and read the entire contents into a string and return said string. pub async fn read_to_string(path: impl AsRef) -> Result { let path = path.as_ref();