feat: ClusterGame supports stdout + stderr

feat: FormattedLog auto scrolling
refactor: Refactored some @ts-expect-error comments
This commit is contained in:
LynithDev 2024-08-16 13:24:17 +02:00
parent 08528ec7c1
commit 6dcf123810
12 changed files with 892 additions and 829 deletions

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,7 @@ type ButtonToggleProps = ButtonProps & {
};
Button.Toggle = (props: ButtonToggleProps) => {
const [split, rest] = splitProps(props, ['checked', 'onChecked', 'onClick', 'class']);
const [split, rest] = splitProps(props, ['checked', 'onChecked', 'onClick']);
const [checked, setChecked] = createSignal(untrack(() => split.checked?.()) || false);
createEffect(on(() => split.checked?.(), newValue => setChecked(newValue || false)));
@ -43,7 +43,7 @@ Button.Toggle = (props: ButtonToggleProps) => {
<Button
onClick={(e) => {
toggle();
// @ts-expect-error -- type error which i cba to resolve
// @ts-expect-error -- JSX doesn't seem to use the same type
split.onClick?.(e);
}}
aria-checked={checked()}

View file

@ -60,9 +60,8 @@ SelectList.Row = (props: SelectListRowProps) => {
if (props.index !== undefined)
setSelected(props.index);
if (split.onClick)
// @ts-expect-error -- la
split.onClick(e);
// @ts-expect-error -- JSX doesn't seem to use the same type
split.onClick?.(e);
}}
class={`${styles.row} ${props.index !== undefined && selected() === props.index ? styles.selected : ''} ${split.class || ''}`}
>

View file

@ -10,8 +10,8 @@
@apply pointer-events-none;
@apply opacity-0 transition-opacity;
@apply absolute left-1/2 -translate-x-1/2 top-0 -translate-y-6/5;
@apply min-w-44 w-max max-w-52;
@apply w-max max-w-52;
@apply backdrop-blur-lg rounded-md bg-black/50 p-2 text-center text-sm text-wrap text-white shadow shadow-black/30;
}
}
}

View file

@ -1,23 +1,79 @@
import { For, createMemo } from 'solid-js';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
import { Index, Show, createMemo, createSignal, untrack } from 'solid-js';
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from 'overlayscrollbars-solid';
import type { Ref } from '@solid-primitives/refs';
import { AlignBottom01Icon } from '@untitled-theme/icons-solid';
import type { OnUpdatedEventListenerArgs, OverlayScrollbars } from 'overlayscrollbars';
import Button from '../base/Button';
import styles from './FormattedLog.module.scss';
interface FormattedLogProps {
log: string;
log?: string | undefined;
codeRef?: Ref<HTMLElement>;
enableAutoScroll?: boolean;
};
function FormattedLog(props: FormattedLogProps) {
const lines = createMemo(() => props.log.split('\n'));
// TODO(perf): Do a "infinite scroll" method of rendering the log. Adding 15918590815 dom elements at once lags the render thread for a bit
const lines = createMemo(() => (props.log?.trim().split('\n') || []));
// eslint-disable-next-line solid/reactivity -- Shouldn't really be tracked
const [shouldScroll, setShouldScroll] = createSignal(props.enableAutoScroll === true);
let overlayScrollbars!: OverlayScrollbarsComponentRef;
function autoScrollUpdated(instance: OverlayScrollbars, _args: OnUpdatedEventListenerArgs) {
if (untrack(shouldScroll) === true)
instance.elements().scrollOffsetElement.scrollTop = instance.elements().scrollOffsetElement.scrollHeight;
}
function autoScrollInitialized(instance: OverlayScrollbars) {
instance.elements().scrollEventElement.addEventListener('wheel', () => {
toggleAutoScroll(false);
});
}
function toggleAutoScroll(value: boolean) {
setShouldScroll(value);
if (value === true)
overlayScrollbars.osInstance()?.update(true);
}
return (
<div class="h-full flex flex-1 overflow-auto rounded-md bg-component-bg p-2 font-medium font-mono">
<OverlayScrollbarsComponent class="relative h-full flex-1">
<code class={styles.log}>
<For each={lines()}>
{line => <Line line={line} />}
</For>
</code>
</OverlayScrollbarsComponent>
<div class="relative h-full flex flex-1 flex-col rounded-md bg-component-bg p-2">
<Show when={props.enableAutoScroll === true}>
<div class="absolute right-0 top-0 z-1 h-6 w-full flex flex-row justify-end border border-gray-05 rounded-t-md bg-primary p-px">
<Button.Toggle
buttonStyle="icon"
children={<AlignBottom01Icon />}
checked={shouldScroll}
onChecked={toggleAutoScroll}
class="h-full! rounded-md! py-1!"
/>
</div>
</Show>
<div
class={`${props.enableAutoScroll === true ? 'mt-6' : ''} h-full flex flex-1 overflow-auto font-medium font-mono`}
>
<OverlayScrollbarsComponent
ref={overlayScrollbars}
events={{
updated: autoScrollUpdated,
initialized: autoScrollInitialized,
}}
class="relative h-full flex-1"
>
<code ref={props.codeRef} class={styles.log}>
<Index each={lines()}>
{(line, index) => {
if (index === lines().length - 1)
return <></>;
return <Line line={line()} />;
}}
</Index>
</code>
</OverlayScrollbarsComponent>
</div>
</div>
);
}
@ -41,10 +97,11 @@ interface LineProps {
* 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)\])?/;
const REGEX_PATTERN = /\[(\d+:\d+:\d+)\] \[(.[^(\n\r/\u2028\u2029]*)\/(\w+)\]:? (?:\[(CHAT)\])?/;
function Line(props: LineProps) {
export function Line(props: LineProps) {
const format = (line: string) => {
line = line.replace(/§./g, '');
const prefix = line.match(REGEX_PATTERN);
const isEmpty = line.trim() === '';

View file

@ -51,9 +51,8 @@ function ClusterCover(props: ClusterCoverProps) {
src={image()}
onError={(e) => {
e.currentTarget.src = split.override ? convertFileSrc(split.override) : defaultCover;
if (split.onError)
// @ts-expect-error -- jsx stuff
split.onError!(e);
// @ts-expect-error -- JSX doesn't seem to use the same type
split.onError?.(e);
}}
/>
</Wrapper>

View file

@ -1,17 +1,18 @@
import { type Params, useNavigate, useSearchParams } from '@solidjs/router';
import { type Params, useSearchParams } from '@solidjs/router';
import { createSignal, onCleanup, onMount } from 'solid-js';
import type { UnlistenFn } from '@tauri-apps/api/event';
import { SlashOctagonIcon } from '@untitled-theme/icons-solid';
import ClusterRoot from './ClusterRoot';
import { render } from 'solid-js/web';
import { bridge } from '~imports';
import Sidebar from '~ui/components/Sidebar';
import useClusterContext from '~ui/hooks/useCluster';
import { TimeAgo } from '~ui/components/DynamicTime';
import Tooltip from '~ui/components/base/Tooltip';
import FormattedLog from '~ui/components/content/FormattedLog';
import FormattedLog, { Line } from '~ui/components/content/FormattedLog';
import Button from '~ui/components/base/Button';
import Modal, { createModal } from '~ui/components/overlay/Modal';
import type { DetailedProcess } from '~bindings';
import useCommand from '~ui/hooks/useCommand';
interface ClusterGameParams extends Params {
process_uuid: string;
@ -23,14 +24,19 @@ interface ClusterGameParams extends Params {
function ClusterGame() {
const [cluster] = useClusterContext();
const [params] = useSearchParams<ClusterGameParams>();
const navigate = useNavigate();
const [log] = useCommand(bridge.commands.getClusterLog, cluster()!.uuid, 'latest.log');
const [isRunning, setIsRunning] = createSignal(true);
const [unlisten, setUnlisten] = createSignal<UnlistenFn>();
let codeRef!: HTMLElement;
onMount(async () => {
const unlisten = await bridge.events.processPayload.listen(({ payload }) => {
if (payload.event === 'finished' && payload.uuid === params.process_uuid && cluster() !== undefined)
ClusterRoot.open(navigate, cluster()!.uuid);
const unlisten = await bridge.events.processPayload.listen((event) => {
if (event.payload.event === 'logging' && event.payload.uuid === params.process_uuid)
render(() => <Line line={event.payload.message} />, codeRef);
if (event.payload.event === 'finished' && event.payload.uuid === params.process_uuid && cluster() !== undefined)
setIsRunning(false);
});
setUnlisten(() => unlisten);
@ -40,11 +46,6 @@ function ClusterGame() {
unlisten()?.();
});
const startedAtFormatted = () => {
const date = new Date(params.started_at!);
return date.toLocaleString();
};
const killModal = createModal(props => (
<Modal.Delete
{...props}
@ -52,6 +53,7 @@ function ClusterGame() {
children="Are you sure you want to kill the game?"
onDelete={() => killProcess(true)}
deleteBtnText="Kill $1"
timeLeft={1}
/>
));
@ -66,19 +68,23 @@ function ClusterGame() {
}
}
const date = new Date(params.started_at!);
return (
<Sidebar.Page>
<h1>Game Running</h1>
<h1>{isRunning() ? 'Game Running' : 'Game Stopped'}</h1>
<div class="flex flex-1 flex-col gap-y-4">
<div class="flex flex-col gap-y-2">
<div class="flex flex-row">
<Tooltip text={startedAtFormatted()}>
Started:
{' '}
<strong>
<TimeAgo timestamp={new Date(params.started_at!).getTime()} />
</strong>
</Tooltip>
<div>
<Tooltip text={date.toLocaleString()}>
Started:
{' '}
<strong>
<TimeAgo timestamp={date.getTime()} />
</strong>
</Tooltip>
</div>
</div>
<p>
@ -88,7 +94,11 @@ function ClusterGame() {
</p>
</div>
<FormattedLog log="aaa" />
<FormattedLog
codeRef={el => codeRef = el}
log={log()?.trim()}
enableAutoScroll={true}
/>
<div class="flex flex-row items-center justify-end gap-x-2">
<Button
@ -96,6 +106,7 @@ function ClusterGame() {
children="Kill"
iconLeft={<SlashOctagonIcon />}
onClick={() => killProcess(false)}
disabled={!isRunning()}
/>
</div>
</div>

View file

@ -88,8 +88,9 @@ function ClusterSidebar() {
});
onMount(async () => {
const unlisten = await bridge.events.processPayload.listen(() => {
refetchProcesses();
const unlisten = await bridge.events.processPayload.listen(({ payload }) => {
if (payload.event === 'started' || payload.event === 'finished')
refetchProcesses();
});
setListener(() => unlisten);

View file

@ -48,12 +48,10 @@ export function createSetting<T>(initial: T | undefined | null, fallback?: T): C
checkGlobal();
});
// @ts-expect-error -- aaa
// @ts-expect-error -- Can't be bothered to fix this type issue
const set: Setter<T> = (value) => {
// @ts-expect-error -- aaa
setRaw(value);
// @ts-expect-error -- aaa
setValue(value);
setRaw(value!);
setValue(value!);
checkGlobal();
};
@ -64,8 +62,7 @@ export function createSetting<T>(initial: T | undefined | null, fallback?: T): C
setValue(() => fallback);
// @ts-expect-error -- aaa
setRaw(() => (raw || null));
setRaw(() => (raw || null)!);
checkGlobal();
};

View file

@ -6,7 +6,7 @@ import solid from 'vite-plugin-solid';
import solidSvg from 'vite-plugin-solid-svg';
import paths from 'vite-tsconfig-paths';
import unocss from 'unocss/vite';
import sentry from '@sentry/vite-plugin';
import * as sentry from '@sentry/vite-plugin';
export default defineConfig(async ({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd(), '') };

View file

@ -231,6 +231,7 @@ pub enum ProcessPayloadType {
Started,
Modified,
Finished,
Logging,
}
#[cfg_attr(feature = "tauri", derive(specta::Type, tauri_specta::Event))]

View file

@ -2,7 +2,9 @@
use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;
use tokio::process::{Child, Command};
use tokio::sync::RwLock;
@ -433,11 +435,43 @@ impl Processor {
censors: HashMap<String, String>,
user: Option<Uuid>,
) -> crate::Result<Arc<RwLock<ProcessorChild>>> {
let proc = command.spawn().map_err(IOError::from)?;
let process = ChildType::ChildProcess(proc);
let pid = process
command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
let mut proc = command.spawn().map_err(IOError::from)?;
let pid = proc
.id()
.ok_or_else(|| anyhow::anyhow!("process failed, couldn't get pid"))?;
let stdout = proc.stdout.take().unwrap();
let stderr = proc.stderr.take().unwrap();
tokio::spawn(async move {
let mut stdout = BufReader::new(stdout).lines();
let mut stderr = BufReader::new(stderr).lines();
while let Ok(line) = tokio::select! {
line = stdout.next_line() => line,
line = stderr.next_line() => line,
} {
if let Some(line) = line {
let mut censored = line.clone();
for (key, value) in censors.iter() {
censored = censored.replace(key, value);
}
if let Err(err) = send_process(uuid, pid, ProcessPayloadType::Logging, &censored).await {
tracing::warn!("failed to send process log: {}", err);
};
}
}
});
let process = ChildType::ChildProcess(proc);
process
.cache(uuid, cluster_path.clone(), post.clone(), user)
.await?;