feat: Log text coloring + formatting

This commit is contained in:
LynithDev 2024-07-30 17:58:56 +02:00
parent a09f6eb483
commit 9b8d2b3f6e
12 changed files with 252 additions and 10 deletions

View file

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

View file

@ -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<String, Str
Ok(log)
}
#[specta::specta]
#[tauri::command]
pub async fn upload_log(uuid: Uuid, log_name: String) -> Result<String, String> {
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<Vec<Cluster>, String> {

View file

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

View file

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

View file

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

View file

@ -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 (
<code class={styles.log}>
<For each={lines()}>
{line => <Line line={line} />}
</For>
</code>
);
}
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 (
<span
{...(isEmpty ? { 'data-empty': 'true' } : {})}
>
{line}
</span>
);
const isChatMsg = prefix[4] === 'CHAT';
return (
<span
data-level={prefix[3]?.toUpperCase()}
{...(isChatMsg ? { 'data-chat': 'true' } : {})}
{...(isEmpty ? { 'data-empty': 'true' } : {})}
>
<span data-date={prefix[1]}>{`[${prefix[1]}]`}</span>
<span>{` [${prefix[2]}/${prefix[3]}]: ${isChatMsg ? `[${prefix[4]}]` : ''}`}</span>
<span>{line.slice(prefix[0].length)}</span>
</span>
);
};
return (
<>{format(props.line)}</>
);
}
export default FormattedLog;

View file

@ -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<string | null>(null);
const [log, setLog] = createSignal<string | null>(null);
const [logContent, setLogContent] = createSignal<string | null>(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() {
<div class="flex flex-row justify-between items-center gap-x-1 h-10">
<h1>Logs</h1>
<div class="flex flex-row gap-x-2">
<Button
buttonStyle="secondary"
onClick={uploadAndOpenLog}
children="Upload"
iconLeft={<Upload01Icon />}
disabled={missingLogs()}
/>
<Dropdown
onChange={changeLog}
class="min-w-50"
@ -79,6 +97,7 @@ function ClusterLogs() {
}}
</For>
</Dropdown>
<Button
buttonStyle="primary"
onClick={openFolder}
@ -89,10 +108,10 @@ function ClusterLogs() {
</div>
</div>
<Show when={log() !== null} fallback={<span>No logs were found.</span>}>
<Show when={logContent() !== null} fallback={<span>No logs were found.</span>}>
<div class="bg-component-bg flex flex-1 h-full font-mono font-medium overflow-auto p-2 rounded-md mt-2">
<OverlayScrollbarsComponent class="flex-1 h-full relative">
<code class="whitespace-pre select-text absolute line-height-normal">{log()}</code>
<FormattedLog log={logContent()!} />
</OverlayScrollbarsComponent>
</div>
</Show>

View file

@ -99,6 +99,14 @@ export default defineConfig({
},
},
code: {
info: '#61AFEF',
warn: '#E5C07B',
error: '#BE5046',
debug: '#2B4BFF',
trace: '#FDFDFD',
},
primary: '#11171C',
secondary: '#192026',
},

View file

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

View file

@ -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<Vec<String>> {
#[tracing::instrument]
pub async fn get_log(path: &ClusterPath, log_name: String) -> crate::Result<String> {
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<String> {
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<String>,
#[serde(default)]
pub error: Option<String>
}
let parsed = serde_json::from_slice::<ResponseBody>(
&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<FutFn>(
path: &ClusterPath,

View file

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

View file

@ -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<std::path::Path>) -> 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<std::path::Path>) -> Result<String, IOError> {
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<std::path::Path>) -> Result<String, IOError> {
let path = path.as_ref();