init
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
:root{color:#dce5df;font-synthesis:none;background:#0b0e0c;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}*{box-sizing:border-box}body{margin:0;overflow:hidden}main{background:radial-gradient(circle at 80% -20%,#193226 0,#0000 38%),#0b0e0c;flex-direction:column;height:100dvh;display:flex}header{color:#8e9c93;border-bottom:1px solid #26322b;justify-content:space-between;align-items:center;height:52px;padding:0 18px;font-size:12px;display:flex}header div{color:#dce5df;align-items:center;gap:9px;display:flex}.dot{background:#b07847;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 3px #b0784722}.dot.live{background:#58d68d;box-shadow:0 0 0 3px #58d68d22}.log{scrollbar-color:#344139 transparent;flex:1;padding:12px 0;overflow:auto}.line{white-space:pre;grid-template-columns:88px minmax(max-content,1fr);gap:14px;min-height:22px;padding:2px 18px;font-size:13px;line-height:18px;display:grid}.line:hover{background:#ffffff08}.time{color:#60766a;-webkit-user-select:none;user-select:none}.loading{text-align:center;color:#8e9c93;padding:8px}button{color:#dce5df;font:inherit;cursor:pointer;background:#193226;border:1px solid #537962;border-radius:18px;padding:9px 14px;position:fixed;bottom:22px;right:22px}.denied{text-align:center;justify-content:center;align-items:center;padding:24px}.denied h1{margin:0 0 12px;font:500 28px system-ui}.denied p{color:#9ba9a0;max-width:580px;font:14px/1.6 system-ui}@media (width<=600px){header{padding:0 10px}.line{grid-template-columns:0 minmax(max-content,1fr);gap:0;padding:2px 10px}.time{visibility:hidden}}
|
||||
+2
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="color-scheme" content="dark"/><title>Minecraft server logs</title> <script type="module" crossorigin src="/assets/index-CFRqyw3R.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-A-Ec5phf.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="color-scheme" content="dark"/><title>Minecraft server logs</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||
Generated
+2326
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{"name":"minecraft-log-viewer-ui","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite","build":"vite build","check":"svelte-check --tsconfig ./tsconfig.json","test":"vitest run"},"devDependencies":{"@sveltejs/vite-plugin-svelte":"latest","@testing-library/svelte":"latest","@types/node":"latest","jsdom":"latest","svelte":"latest","svelte-check":"latest","typescript":"latest","vite":"latest","vitest":"latest"}}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from 'svelte';import {backoff,mergeLines,type LogLine,type Page} from './lib';
|
||||
let lines:LogLine[]=[];let before:string|null=null;let hasMore=true;let state:'Connecting'|'Live'|'Reconnecting'|'Disconnected'|'Access denied'='Connecting';let viewport:HTMLDivElement;let atBottom=true;let unseen=0;let loading=false;let socket:WebSocket|null=null;let stopped=false;
|
||||
async function fetchPage(cursor:string|null,limit=1000){const endpoint=cursor?`/api/logs/history?before=${encodeURIComponent(cursor)}&limit=${limit}`:`/api/logs/recent?limit=${limit}`;const response=await fetch(endpoint);if(response.status===403){state='Access denied';throw new Error('denied')}if(!response.ok)throw new Error('history');return response.json() as Promise<Page>}
|
||||
async function initial(){const page=await fetchPage(null);lines=mergeLines([],page.lines);before=page.next_before;hasMore=page.has_more;requestAnimationFrame(scrollLive);}
|
||||
async function older(){if(loading||!hasMore||!before)return;loading=true;const oldHeight=viewport.scrollHeight;try{const page=await fetchPage(before);const ids=new Set(lines.map(x=>x.id));lines=[...page.lines.filter(x=>!ids.has(x.id)),...lines];before=page.next_before;hasMore=page.has_more;requestAnimationFrame(()=>viewport.scrollTop+=viewport.scrollHeight-oldHeight);}finally{loading=false}}
|
||||
function connect(attempt=0){if(stopped||state==='Access denied')return;state=attempt?'Reconnecting':'Connecting';const scheme=location.protocol==='https:'?'wss':'ws';socket=new WebSocket(`${scheme}://${location.host}/api/live`);socket.onopen=async()=>{state='Live';try{const gap=await fetchPage(null,1000);lines=mergeLines(lines,gap.lines);if(atBottom)requestAnimationFrame(scrollLive)}catch{}};socket.onmessage=(event)=>{const message=JSON.parse(event.data);if(message.type==='log_lines'){lines=mergeLines(lines,message.lines);if(atBottom)requestAnimationFrame(scrollLive);else unseen+=message.lines.length}if(message.type==='resync_required')socket?.close()};socket.onclose=()=>{if(stopped||state==='Access denied')return;state='Reconnecting';setTimeout(()=>connect(attempt+1),backoff(attempt))};socket.onerror=()=>socket?.close()}
|
||||
function scrollLive(){viewport?.scrollTo({top:viewport.scrollHeight});unseen=0}
|
||||
function scroll(){atBottom=viewport.scrollHeight-viewport.scrollTop-viewport.clientHeight<40;if(atBottom)unseen=0;if(viewport.scrollTop<200)older()}
|
||||
onMount(()=>{initial().then(()=>connect()).catch(()=>{});return()=>{stopped=true;socket?.close()}})
|
||||
</script>
|
||||
{#if state==='Access denied'}<main class="denied"><h1>Access denied</h1><p>This viewer is available only from an IP address associated with a currently whitelisted player who has successfully joined the server.</p></main>{:else}<main><header><div><span class:live={state==='Live'} class="dot"></span><strong>{state}</strong></div><span>{lines.length.toLocaleString()} lines retained</span></header><!-- svelte-ignore a11y_no_noninteractive_tabindex --><div class="log" bind:this={viewport} on:scroll={scroll} tabindex="0" role="log" aria-live="off" aria-label="Minecraft server log">{#if loading}<div class="loading">Loading older logs…</div>{/if}{#each lines as line (line.id)}<div class="line"><span class="time">{line.timestamp??''}</span><span>{line.text}</span></div>{/each}</div>{#if unseen}<button on:click={scrollLive}>{unseen} new {unseen===1?'line':'lines'} ↓</button>{/if}</main>{/if}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {describe,expect,it} from 'vitest';import {backoff,mergeLines,type LogLine} from './lib';
|
||||
const line=(id:string):LogLine=>({id,timestamp:null,text:id,source:'latest.log'});
|
||||
describe('live/history reconciliation',()=>{it('deduplicates overlapping snapshots',()=>expect(mergeLines([line('a'),line('b')],[line('b'),line('c')]).map(x=>x.id)).toEqual(['a','b','c']));it('bounds retained client history',()=>expect(mergeLines([line('a'),line('b')],[line('c')],2).map(x=>x.id)).toEqual(['b','c']));it('caps reconnect backoff',()=>expect(backoff(99)).toBe(30000));});
|
||||
@@ -0,0 +1,4 @@
|
||||
export type LogLine={id:string;timestamp:string|null;text:string;source:string};
|
||||
export type Page={lines:LogLine[];next_before:string|null;has_more:boolean};
|
||||
export function mergeLines(current:LogLine[],incoming:LogLine[],max=10000):LogLine[]{const seen=new Set(current.map(l=>l.id));const merged=[...current,...incoming.filter(l=>!seen.has(l.id))];return merged.slice(Math.max(0,merged.length-max));}
|
||||
export function backoff(attempt:number):number{return Math.min(30000,500*2**Math.min(attempt,6));}
|
||||
@@ -0,0 +1 @@
|
||||
import './style.css';import App from './App.svelte';new App({target:document.getElementById('app')!});
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#dce5df;background:#0b0e0c;font-synthesis:none}*{box-sizing:border-box}body{margin:0;overflow:hidden}main{height:100dvh;display:flex;flex-direction:column;background:radial-gradient(circle at 80% -20%,#193226 0,transparent 38%),#0b0e0c}header{height:52px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid #26322b;color:#8e9c93;font-size:12px}header div{display:flex;gap:9px;align-items:center;color:#dce5df}.dot{width:8px;height:8px;border-radius:50%;background:#b07847;box-shadow:0 0 0 3px #b0784722}.dot.live{background:#58d68d;box-shadow:0 0 0 3px #58d68d22}.log{flex:1;overflow:auto;padding:12px 0;scrollbar-color:#344139 transparent}.line{display:grid;grid-template-columns:88px minmax(max-content,1fr);gap:14px;padding:2px 18px;min-height:22px;line-height:18px;font-size:13px;white-space:pre}.line:hover{background:#ffffff08}.time{color:#60766a;user-select:none}.loading{text-align:center;color:#8e9c93;padding:8px}button{position:fixed;right:22px;bottom:22px;border:1px solid #537962;border-radius:18px;background:#193226;color:#dce5df;padding:9px 14px;font:inherit;cursor:pointer}.denied{justify-content:center;align-items:center;padding:24px;text-align:center}.denied h1{font:500 28px system-ui;margin:0 0 12px}.denied p{font:14px/1.6 system-ui;color:#9ba9a0;max-width:580px}@media(max-width:600px){header{padding:0 10px}.line{grid-template-columns:0 minmax(max-content,1fr);gap:0;padding:2px 10px}.time{visibility:hidden}}
|
||||
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ESNext","lib":["ESNext","DOM"],"module":"ESNext","moduleResolution":"bundler","strict":true,"types":["vite/client","node"]},"include":["src/**/*.ts","src/**/*.svelte","vite.config.ts","vitest.config.ts"]}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
export default defineConfig({ plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true } });
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
export default defineConfig({ plugins: [svelte()], test: { environment: 'jsdom' } });
|
||||
Reference in New Issue
Block a user