feat: ClusterGame supports stdout + stderr
feat: FormattedLog auto scrolling refactor: Refactored some @ts-expect-error comments
This commit is contained in:
parent
08528ec7c1
commit
6dcf123810
12 changed files with 892 additions and 829 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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 || ''}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() === '';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(), '') };
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ pub enum ProcessPayloadType {
|
|||
Started,
|
||||
Modified,
|
||||
Finished,
|
||||
Logging,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "tauri", derive(specta::Type, tauri_specta::Event))]
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue