Skip to content
Open
3 changes: 3 additions & 0 deletions core/bindings/ConsoleQueryParams.ts
Original file line number Diff line number Diff line change
@@ -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, }
8 changes: 5 additions & 3 deletions core/src/db/read.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
8 changes: 2 additions & 6 deletions core/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions core/src/handlers/console.rs
Original file line number Diff line number Diff line change
@@ -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<AppState>,
Path(uuid): Path<String>,
Query(query_params): Query<ConsoleQueryParams>,
) -> Result<Json<Vec<ClientEvent>>, 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<ClientEvent> = 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<ClientEvent> = 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)
}
1 change: 1 addition & 0 deletions core/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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::{
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -724,4 +726,4 @@ pub async fn run(
guard,
shutdown_tx,
)
}
}
Binary file modified core/test.db
Binary file not shown.
3 changes: 3 additions & 0 deletions dashboard/src/bindings/ConsoleQueryParams.ts
Original file line number Diff line number Diff line change
@@ -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, }
53 changes: 40 additions & 13 deletions dashboard/src/components/GameConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<HTMLOListElement>(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);

Expand All @@ -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',
Expand Down Expand Up @@ -129,7 +150,7 @@ export default function GameConsole() {
};

return (
<div className="relative flex h-full w-full grow flex-col rounded-lg border border-gray-faded/30">
<div className="border-gray-faded/30 relative flex h-full w-full grow flex-col rounded-lg border">
<Tooltip
overlay={<span>{consoleStatusMessage}</span>}
placement="bottom"
Expand All @@ -139,41 +160,47 @@ export default function GameConsole() {
>
<FontAwesomeIcon
icon={faCircle}
className={`absolute top-0 right-0 select-none p-1.5 text-small ${consoleStatusColor}`}
className={`text-small absolute top-0 right-0 select-none p-1.5 ${consoleStatusColor}`}
/>
</Tooltip>
{!canAccessConsole || consoleStatus === 'no-permission' ? (
<ErrorGraphic
icon={faServer}
message="You don't have permission to access this console"
className="rounded-t-lg border-b border-gray-faded/30"
className="border-gray-faded/30 rounded-t-lg border-b"
iconClassName="text-gray-400"
messageClassName="text-white/50"
/>
) : consoleLog.length === 0 ? (
<ErrorGraphic
icon={faServer}
message="No console messages yet"
className="rounded-t-lg border-b border-gray-faded/30"
className="border-gray-faded/30 rounded-t-lg border-b"
iconClassName="text-gray-400"
messageClassName="text-white/50"
/>
) : (
<ol
className="font-light flex h-0 grow flex-col overflow-y-auto whitespace-pre-wrap break-words rounded-t-lg border-b border-gray-faded/30 bg-gray-900 py-3 font-mono text-small tracking-tight text-gray-300"
className="border-gray-faded/30 text-small flex h-0 grow flex-col overflow-y-auto whitespace-pre-wrap break-words rounded-t-lg border-b bg-gray-900 py-3 font-mono font-light tracking-tight text-gray-300"
ref={listRef}
onScroll={(e: React.SyntheticEvent) => {
if (e.currentTarget.scrollTop !== 0) return;
setFetchingItems(true);
}}
>

{fetchingItems && <LogLoading loadingText='Getting new logs...'/>}
{consoleLog.map((line) => (
<li
key={line.snowflake}
className="py-[0.125rem] px-4 hover:bg-gray-800"
className="px-4 py-[0.125rem] hover:bg-gray-800"
>
{line.message}
</li>
))}
</ol>
)}
<div className="font-mono text-small">
<div className="text-small font-mono">
<form
noValidate
autoComplete={DISABLE_AUTOFILL}
Expand All @@ -186,7 +213,7 @@ export default function GameConsole() {
}}
>
<input
className="w-full rounded-b-lg bg-gray-850 py-3 px-4 text-gray-300 outline-white/50 placeholder:text-gray-500 focus-visible:outline focus-visible:outline-2 disabled:placeholder:text-gray-500"
className="bg-gray-850 w-full rounded-b-lg px-4 py-3 text-gray-300 outline-white/50 placeholder:text-gray-500 focus-visible:outline focus-visible:outline-2 disabled:placeholder:text-gray-500"
placeholder={consoleInputMessage || 'Enter command...'}
value={command}
onChange={(e) => setCommand(e.target.value)}
Expand Down
25 changes: 25 additions & 0 deletions dashboard/src/components/LogLoading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import clsx from 'clsx';
export default function LogLoading(
{loadingText = ""}:
{loadingText: string}
) {
return (
<div className = {clsx(
"flex flex-row items-center gap-x-1.5",
"rounded-md py-1 px-2",
)}>
<div className="w-4 h-4 ">
<div
className="top-0 left-0 h-full w-full animate-spin rounded-full border-4 border-t-4"
style={{
borderBottomColor: 'transparent',
color: '#59B2F3'
}}
></div>
</div>

<p className="grow truncate text-left italic">{loadingText}</p>
</div>

);
}
Loading