diff --git a/core/bindings/ConsoleQueryParams.ts b/core/bindings/ConsoleQueryParams.ts new file mode 100644 index 00000000..968114d8 --- /dev/null +++ b/core/bindings/ConsoleQueryParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface ConsoleQueryParams { start_snowflake_id: bigint, count: number, } \ No newline at end of file diff --git a/core/src/db/read.rs b/core/src/db/read.rs index 4b786280..eb898bb3 100644 --- a/core/src/db/read.rs +++ b/core/src/db/read.rs @@ -1,9 +1,11 @@ use crate::{ - error::Error, output_types::ClientEvent, - prelude::LODESTONE_EPOCH_MIL, events::EventQuery, + error::{Error, ErrorKind}, + events::EventQuery, + output_types::ClientEvent, + prelude::LODESTONE_EPOCH_MIL, }; -use color_eyre::eyre::Context; +use color_eyre::eyre::{eyre, Context}; use sqlx::sqlite::SqlitePool; use tracing::error; diff --git a/core/src/events.rs b/core/src/events.rs index 4ea0f634..58dcdd33 100644 --- a/core/src/events.rs +++ b/core/src/events.rs @@ -217,12 +217,8 @@ pub enum ProgressionEndValue { #[ts(export)] #[serde(tag = "type")] pub enum ProgressionStartValue { - InstanceCreation { - instance_uuid: InstanceUuid, - }, - InstanceDelete { - instance_uuid: InstanceUuid, - }, + InstanceCreation { instance_uuid: InstanceUuid }, + InstanceDelete { instance_uuid: InstanceUuid }, } // the backend will keep exactly 1 copy of ProgressionStart, and 1 copy of ProgressionUpdate OR ProgressionEnd diff --git a/core/src/handlers/console.rs b/core/src/handlers/console.rs new file mode 100644 index 00000000..2e028029 --- /dev/null +++ b/core/src/handlers/console.rs @@ -0,0 +1,103 @@ +use crate::error::Error; +use crate::events::{EventQuery, EventType, InstanceEventKind}; +use crate::{ + output_types::ClientEvent, + types::{InstanceUuid, TimeRange}, + AppState, +}; +use axum::{ + extract::{Path, Query}, + routing::get, + Json, Router, +}; +use color_eyre::eyre::{eyre, Context}; +use serde::{Deserialize, Serialize}; +use sqlx::sqlite::SqlitePool; +use tracing::error; +use ts_rs::TS; + +#[derive(Debug, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ConsoleQueryParams { + start_snowflake_id: i64, + count: u32, +} + +async fn get_console_messages( + axum::extract::State(state): axum::extract::State, + Path(uuid): Path, + Query(query_params): Query, +) -> Result>, Error> { + let time_range = TimeRange { + start: query_params.start_snowflake_id, + end: i64::MAX, + }; + + let pool = &state.sqlite_pool; + + let mut connection = pool + .acquire() + .await + .context("Failed to aquire connection to db")?; + + let limit_num = &query_params.count * 2 + 10; + + let rows = sqlx::query!( + r#" +SELECT +event_value, details, snowflake, level, caused_by_user_id, instance_id +FROM ClientEvents +WHERE snowflake <= ($1) AND event_value IS NOT NULL +ORDER BY snowflake DESC +LIMIT $2 +"#, + query_params.start_snowflake_id, + limit_num, // hacky, but need more since filter + ) + .fetch_all(&mut connection) + .await + .context("Failed to fetch events")?; + + let mut parsed_client_events: Vec = Vec::new(); + for row in rows { + if let Some(event_value) = &row.event_value { + if let Ok(client_event) = serde_json::from_str(event_value) { + parsed_client_events.push(client_event); + } else { + error!("Failed to parse client event: {}", event_value); + } + } else { + error!("Failed to parse row"); + } + } + + let event_query = EventQuery { + event_levels: None, + event_types: Some(vec![EventType::InstanceEvent]), + instance_event_types: Some(vec![InstanceEventKind::InstanceOutput]), + user_event_types: None, + event_user_ids: None, + event_instance_ids: Some(vec![InstanceUuid::from(uuid)]), + bearer_token: None, + time_range: None, + }; + + let filtered: Vec = parsed_client_events + .into_iter() + .filter(|client_event| event_query.filter(client_event)) + .collect(); + + let filtered = if filtered.len() as u32 > query_params.count { + filtered[0..query_params.count as usize].to_vec() + } else { + filtered + }; + + return Ok(Json(filtered)); +} + +pub fn get_console_routes(state: AppState) -> Router { + Router::new() + .route("/instance/:uuid/console", get(get_console_messages)) + .with_state(state) +} diff --git a/core/src/handlers/mod.rs b/core/src/handlers/mod.rs index b8efeac8..691b3a1b 100644 --- a/core/src/handlers/mod.rs +++ b/core/src/handlers/mod.rs @@ -2,6 +2,7 @@ // pub mod instance; // pub mod users; pub mod checks; +pub mod console; pub mod core_info; pub mod events; pub mod gateway; diff --git a/core/src/lib.rs b/core/src/lib.rs index c676184c..4e02e93b 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,6 +1,7 @@ #![allow(clippy::comparison_chain, clippy::type_complexity)] use crate::event_broadcaster::EventBroadcaster; +use crate::handlers::console::get_console_routes; use crate::migration::migrate; use crate::prelude::{ init_app_state, init_paths, lodestone_path, path_to_global_settings, path_to_stores, @@ -43,6 +44,7 @@ use prelude::GameInstance; use reqwest::{header, Method}; use ringbuffer::{AllocRingBuffer, RingBufferWrite}; +use fs3::FileExt; use semver::Version; use sqlx::{sqlite::SqliteConnectOptions, Pool}; use std::{ @@ -68,7 +70,6 @@ use tracing_subscriber::{prelude::__tracing_subscriber_SubscriberExt, EnvFilter} use traits::{t_configurable::TConfigurable, t_server::MonitorReport, t_server::TServer}; use types::{DotLodestoneConfig, InstanceUuid}; use uuid::Uuid; -use fs3::FileExt; pub mod auth; pub mod db; @@ -613,6 +614,7 @@ pub async fn run( .merge(get_global_fs_routes(shared_state.clone())) .merge(get_global_settings_routes(shared_state.clone())) .merge(get_gateway_routes(shared_state.clone())) + .merge(get_console_routes(shared_state.clone())) .layer(cors) .layer(trace); let app = Router::new().nest("/api/v1", api_routes); @@ -724,4 +726,4 @@ pub async fn run( guard, shutdown_tx, ) -} \ No newline at end of file +} diff --git a/core/test.db b/core/test.db index e69a8a1f..c79f935a 100644 Binary files a/core/test.db and b/core/test.db differ diff --git a/dashboard/src/bindings/ConsoleQueryParams.ts b/dashboard/src/bindings/ConsoleQueryParams.ts new file mode 100644 index 00000000..968114d8 --- /dev/null +++ b/dashboard/src/bindings/ConsoleQueryParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface ConsoleQueryParams { start_snowflake_id: bigint, count: number, } \ No newline at end of file diff --git a/dashboard/src/components/GameConsole.tsx b/dashboard/src/components/GameConsole.tsx index d33ae6d8..69f088c7 100644 --- a/dashboard/src/components/GameConsole.tsx +++ b/dashboard/src/components/GameConsole.tsx @@ -5,8 +5,9 @@ import { useConsoleStream } from 'data/ConsoleStream'; import { InstanceContext } from 'data/InstanceContext'; import { CommandHistoryContext } from 'data/CommandHistoryContext'; import { useUserAuthorized } from 'data/UserInfo'; +import LogLoading from './LogLoading'; import Tooltip from 'rc-tooltip'; -import { useContext, useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import { useRef, useState } from 'react'; import { usePrevious } from 'utils/hooks'; import { DISABLE_AUTOFILL } from 'utils/util'; @@ -24,18 +25,21 @@ export default function GameConsole() { 'can_access_instance_console', uuid ); - const { consoleLog, consoleStatus } = useConsoleStream(uuid); + const { consoleLog, consoleStatus, fetchConsolePage } = useConsoleStream(uuid, undefined); const [command, setCommand] = useState(''); const { commandHistory, appendCommandHistory } = useContext( CommandHistoryContext ); + const [additionalLogs, setAdditionaLogs] = useState(0); const [commandNav, setCommandNav] = useState(commandHistory.length); + const [initialScroll, setInitialScroll] = useState(false); + const [fetchingItems, setFetchingItems] = useState(false); const listRef = useRef(null); const isAtBottom = listRef.current ? listRef.current.scrollHeight - - listRef.current.scrollTop - - listRef.current.clientHeight < - autoScrollThreshold + listRef.current.scrollTop - + listRef.current.clientHeight < + autoScrollThreshold : false; const oldIsAtBottom = usePrevious(isAtBottom); @@ -52,6 +56,23 @@ export default function GameConsole() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [consoleLog]); + // scroll to bottom of screen on initial load + useEffect(() => { + if (listRef.current && !initialScroll) { + setInitialScroll(true); + scrollToBottom(); + } + }) + + useEffect(() => { + if (!consoleLog || consoleLog.length <= 0) { + return; + } + fetchConsolePage(consoleLog[0].snowflake as unknown as bigint, additionalLogs + 40); + setAdditionaLogs(currNumLogs => currNumLogs + 40); + setFetchingItems(false); + }, [fetchingItems]) + const sendCommand = (command: string) => { axios({ method: 'post', @@ -129,7 +150,7 @@ export default function GameConsole() { }; return ( -
+
{consoleStatusMessage}} placement="bottom" @@ -139,14 +160,14 @@ export default function GameConsole() { > {!canAccessConsole || consoleStatus === 'no-permission' ? ( @@ -154,26 +175,32 @@ export default function GameConsole() { ) : (
    { + if (e.currentTarget.scrollTop !== 0) return; + setFetchingItems(true); + }} > + + {fetchingItems && } {consoleLog.map((line) => (
  1. {line.message}
  2. ))}
)} -
+
setCommand(e.target.value)} diff --git a/dashboard/src/components/LogLoading.tsx b/dashboard/src/components/LogLoading.tsx new file mode 100644 index 00000000..c9941b55 --- /dev/null +++ b/dashboard/src/components/LogLoading.tsx @@ -0,0 +1,25 @@ +import clsx from 'clsx'; +export default function LogLoading( + {loadingText = ""}: + {loadingText: string} +) { + return ( +
+
+
+
+ +

{loadingText}

+
+ + ); +} \ No newline at end of file diff --git a/dashboard/src/data/ConsoleEvent.ts b/dashboard/src/data/ConsoleEvent.ts new file mode 100644 index 00000000..848fdc8d --- /dev/null +++ b/dashboard/src/data/ConsoleEvent.ts @@ -0,0 +1,52 @@ +import { getSnowflakeTimestamp } from './../utils/util'; +import { InstanceEvent } from './../bindings/InstanceEvent'; +import { ConsoleQueryParams } from './../bindings/ConsoleQueryParams'; +import { match, otherwise } from 'variant'; +import { ClientEvent } from 'bindings/ClientEvent'; +import { getConsoleEvents } from 'utils/apis'; + +// simplified version of a ClientEvent with just InstanceOutput +export type ConsoleEvent = { + timestamp: number; + snowflake: string; + detail: string; + uuid: string; + name: string; + message: string; +}; + +// function to convert a ClientEvent to a ConsoleEvent +export const toConsoleEvent = (event: ClientEvent): ConsoleEvent => { + const event_inner: InstanceEvent = match( + event.event_inner, + otherwise( + { + InstanceEvent: (instanceEvent) => instanceEvent, + }, + () => { + throw new Error('Expected InstanceEvent'); + } + ) + ); + + const message = match( + event_inner.instance_event_inner, + otherwise( + { + InstanceOutput: (instanceOutput) => instanceOutput.message, + }, + () => { + throw new Error('Expected InstanceOutput'); + } + ) + ); + + return { + timestamp: getSnowflakeTimestamp(event.snowflake), + snowflake: event.snowflake, + detail: event.details, + uuid: event_inner.instance_uuid, + name: event_inner.instance_name, + message: message, + }; +}; diff --git a/dashboard/src/data/ConsoleStream.ts b/dashboard/src/data/ConsoleStream.ts index 36c123f1..c42f3680 100644 --- a/dashboard/src/data/ConsoleStream.ts +++ b/dashboard/src/data/ConsoleStream.ts @@ -1,11 +1,11 @@ -import { getSnowflakeTimestamp, LODESTONE_PORT } from './../utils/util'; -import { InstanceEvent } from './../bindings/InstanceEvent'; -import { match, otherwise } from 'variant'; +import { LODESTONE_PORT } from './../utils/util'; import { useUserAuthorized } from 'data/UserInfo'; import axios from 'axios'; import { useContext, useEffect, useRef, useState } from 'react'; import { LodestoneContext } from './LodestoneContext'; import { ClientEvent } from 'bindings/ClientEvent'; +import { ConsoleEvent, toConsoleEvent } from 'data/ConsoleEvent'; +import { getConsoleEvents } from 'utils/apis'; export type ConsoleStreamStatus = | 'no-permission' @@ -16,52 +16,6 @@ export type ConsoleStreamStatus = | 'closed' | 'error'; -// simplified version of a ClientEvent with just InstanceOutput -export type ConsoleEvent = { - timestamp: number; - snowflake: string; - detail: string; - uuid: string; - name: string; - message: string; -}; - -// function to convert a ClientEvent to a ConsoleEvent -const toConsoleEvent = (event: ClientEvent): ConsoleEvent => { - const event_inner: InstanceEvent = match( - event.event_inner, - otherwise( - { - InstanceEvent: (instanceEvent) => instanceEvent, - }, - () => { - throw new Error('Expected InstanceEvent'); - } - ) - ); - - const message = match( - event_inner.instance_event_inner, - otherwise( - { - InstanceOutput: (instanceOutput) => instanceOutput.message, - }, - () => { - throw new Error('Expected InstanceOutput'); - } - ) - ); - - return { - timestamp: getSnowflakeTimestamp(event.snowflake), - snowflake: event.snowflake, - detail: event.details, - uuid: event_inner.instance_uuid, - name: event_inner.instance_name, - message: message, - }; -}; - /** * Does two things: * 1. calls useEffect to fetch the console stream @@ -73,10 +27,11 @@ const toConsoleEvent = (event: ClientEvent): ConsoleEvent => { * @param uuid the uuid of the instance to subscribe to * @return whatever useQuery returns */ -export const useConsoleStream = (uuid: string) => { +export const useConsoleStream = (uuid: string, logLimit: number | undefined) => { const { core, token } = useContext(LodestoneContext); const { address, port, apiVersion, protocol } = core; const [consoleLog, setConsoleLog] = useState([]); + const [limit, setLimit] = useState(logLimit); const [status, setStatusInner] = useState('loading'); //callbacks should use statusRef.current instead of status const statusRef = useRef('loading'); statusRef.current = status; @@ -102,10 +57,28 @@ export const useConsoleStream = (uuid: string) => { const mergedLog = [...oldLog, ...consoleEvents]; // this is slow ik - return mergedLog.filter( + const filteredLog = mergedLog.filter( + (event, index) => + mergedLog.findIndex((e) => e.snowflake === event.snowflake) === index + ); + + return filteredLog.slice(limit ? -limit : 0); + }); + }; + + const fetchConsolePage = async (snowflake: bigint, count: number) => { + console.log("called with ", snowflake); + console.log(consoleLog[0].snowflake); + const paginatedEvents = await getConsoleEvents(uuid, { start_snowflake_id: snowflake, count: count }) + setLimit(undefined); + setConsoleLog((oldLog) => { + const mergedLog = [...paginatedEvents.reverse(), ...oldLog]; + const filteredLog = mergedLog.filter( (event, index) => mergedLog.findIndex((e) => e.snowflake === event.snowflake) === index ); + console.log(oldLog.length, filteredLog.length) + return filteredLog.slice(limit ? -limit : 0); }); }; @@ -118,8 +91,7 @@ export const useConsoleStream = (uuid: string) => { try { const websocket = new WebSocket( - `${protocol === 'https' ? 'wss' : 'ws'}://${address}:${ - port ?? LODESTONE_PORT + `${protocol === 'https' ? 'wss' : 'ws'}://${address}:${port ?? LODESTONE_PORT }/api/${apiVersion}/instance/${uuid}/console/stream?token=Bearer ${token}` ); @@ -162,5 +134,6 @@ export const useConsoleStream = (uuid: string) => { return { consoleLog, consoleStatus: status, + fetchConsolePage, }; }; diff --git a/dashboard/src/utils/apis.ts b/dashboard/src/utils/apis.ts index a68c2f77..c38fec76 100644 --- a/dashboard/src/utils/apis.ts +++ b/dashboard/src/utils/apis.ts @@ -21,6 +21,9 @@ import { CopyInstanceFileRequest } from 'bindings/CopyInstanceFileRequest'; import { ZipRequest } from 'bindings/ZipRequest'; import { TaskEntry } from 'bindings/TaskEntry'; import { HistoryEntry } from 'bindings/HistoryEntry'; +import { ConsoleQueryParams } from 'bindings/ConsoleQueryParams'; +import { ClientEvent } from 'bindings/ClientEvent'; +import { ConsoleEvent, toConsoleEvent } from 'data/ConsoleEvent'; /*********************** * Start Files API @@ -280,8 +283,7 @@ export const zipInstanceFiles = async ( return; } else { toast.info( - `Zipping ${zipRequest.target_relative_paths.length} item${ - zipRequest.target_relative_paths.length > 1 ? 's' : '' + `Zipping ${zipRequest.target_relative_paths.length} item${zipRequest.target_relative_paths.length > 1 ? 's' : '' }...` ); } @@ -491,3 +493,18 @@ export const killTask = async ( /*********************** * End Tasks/Macro API ***********************/ + +export const getConsoleEvents = async (uuid: string, query_params: ConsoleQueryParams) => { + const clientEventList = await axiosWrapper<[ClientEvent]>({ + method: 'get', + url: `/instance/${uuid}/console?start_snowflake_id=${query_params.start_snowflake_id}&count=${query_params.count}`, + }); + + const consoleEventList = clientEventList.map((clientEvent) => toConsoleEvent(clientEvent)); + + return consoleEventList; +}; + +/*********************** + * End Console API + ***********************/