feat: Log text coloring + formatting
This commit is contained in:
parent
a09f6eb483
commit
9b8d2b3f6e
12 changed files with 252 additions and 10 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
apps/frontend/src/ui/components/content/FormattedLog.tsx
Normal file
79
apps/frontend/src/ui/components/content/FormattedLog.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -99,6 +99,14 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
|
||||
code: {
|
||||
info: '#61AFEF',
|
||||
warn: '#E5C07B',
|
||||
error: '#BE5046',
|
||||
debug: '#2B4BFF',
|
||||
trace: '#FDFDFD',
|
||||
},
|
||||
|
||||
primary: '#11171C',
|
||||
secondary: '#192026',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue