From ffd8b3e5c91b86e38f09af75782aab2a6cd4ab8e Mon Sep 17 00:00:00 2001 From: JohannesBOT Date: Tue, 26 May 2026 12:27:23 +0200 Subject: [PATCH] init --- .dockerignore | 6 ++ .env.example | 6 ++ .gitignore | 7 ++ Dockerfile | 32 ++++++ README.md | 83 ++++++++++++++- docker/40-self-signed-ssl.sh | 25 +++++ index.html | 18 ++++ nginx.conf | 29 +++++ package.json | 26 +++++ src/App.vue | 85 +++++++++++++++ src/api/config.ts | 23 ++++ src/api/http.ts | 25 +++++ src/api/types.ts | 47 +++++++++ src/components/FilterBar.vue | 139 ++++++++++++++++++++++++ src/components/HistoryChart.vue | 122 ++++++++++++++++++++++ src/components/ItemTable.vue | 135 ++++++++++++++++++++++++ src/composables/useLiveData.ts | 117 +++++++++++++++++++++ src/env.d.ts | 18 ++++ src/main.ts | 6 ++ src/router/index.ts | 23 ++++ src/style.css | 138 ++++++++++++++++++++++++ src/utils/format.ts | 75 +++++++++++++ src/views/ItemHistoryView.vue | 180 ++++++++++++++++++++++++++++++++ src/views/LiveView.vue | 124 ++++++++++++++++++++++ tsconfig.json | 9 ++ vite.config.ts | 17 +++ 26 files changed, 1514 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker/40-self-signed-ssl.sh create mode 100644 index.html create mode 100644 nginx.conf create mode 100644 package.json create mode 100644 src/App.vue create mode 100644 src/api/config.ts create mode 100644 src/api/http.ts create mode 100644 src/api/types.ts create mode 100644 src/components/FilterBar.vue create mode 100644 src/components/HistoryChart.vue create mode 100644 src/components/ItemTable.vue create mode 100644 src/composables/useLiveData.ts create mode 100644 src/env.d.ts create mode 100644 src/main.ts create mode 100644 src/router/index.ts create mode 100644 src/style.css create mode 100644 src/utils/format.ts create mode 100644 src/views/ItemHistoryView.vue create mode 100644 src/views/LiveView.vue create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a9ca5bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.git/ +.env +certs/ +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e968936 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Base URL of the backend HTTP API. +VITE_API_BASE=https://localhost:3000 + +# Base URL of the backend websocket. Optional: when omitted it is derived +# from VITE_API_BASE by swapping the protocol (https -> wss). +VITE_WS_BASE=wss://localhost:3000 diff --git a/.gitignore b/.gitignore index f0c821f..42d8364 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,10 @@ docs/_book # TODO: where does this rule come from? test/ +node_modules/ +dist/ +*.local +.env +.DS_Store +certs/ +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..920bda4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# ---- Build stage ----------------------------------------------------- +FROM node:22-alpine AS build + +WORKDIR /app + +# Backend URLs are baked in at build time (Vite inlines VITE_* variables). +ARG VITE_API_BASE=https://localhost:3000 +ARG VITE_WS_BASE +ENV VITE_API_BASE=$VITE_API_BASE +ENV VITE_WS_BASE=$VITE_WS_BASE + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +# ---- Runtime stage --------------------------------------------------- +FROM nginx:1.27-alpine AS runtime + +# openssl is needed to generate the self-signed certificate on first start. +RUN apk add --no-cache openssl + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY docker/40-self-signed-ssl.sh /docker-entrypoint.d/40-self-signed-ssl.sh +RUN chmod +x /docker-entrypoint.d/40-self-signed-ssl.sh + +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 443 + +# The base nginx image entrypoint runs every script in /docker-entrypoint.d. diff --git a/README.md b/README.md index 43a216f..38f6051 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,83 @@ -# mc-computer-craft-api-frontend +# Minecraft ComputerCraft API – Frontend +Vue 3 + TypeScript single page application for the +[ME System Monitor backend](../mc-cc-api-backend). It shows the live contents +of a Minecraft ME system over a websocket and renders per-item history charts. + +## Features + +- **Live view** over a websocket (`wss://…/api/ws/live`) – updates in real time. +- **Persistent filters** – text search, sort column/order and mod filter are + kept in `localStorage` and re-applied by the server on every update. +- **Item history** – clicking an item opens a detail page with a Chart.js line + chart plus current / min / max / change statistics. +- **Abbreviated numbers** – `12000 → 12k`, `43452542 → 43.45M`. The full value + is available as a tooltip. +- **HTTPS / WSS by default** via a self-signed certificate. + +## Tech stack + +| Concern | Choice | +| ---------- | ----------------------------------- | +| Framework | Vue 3 (` + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..d1535d9 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 443 ssl; + http2 on; + server_name _; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + + root /usr/share/nginx/html; + index index.html; + + # Single page application: unknown paths fall back to index.html so that + # client-side routes such as /item/minecraft:iron_ingot keep working. + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|gif|ico)$ { + expires 7d; + add_header Cache-Control "public"; + } +} + +# Redirect plain HTTP to HTTPS. +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..33933b5 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "mc-cc-api-frontend", + "version": "1.0.0", + "description": "Vue 3 + TypeScript dashboard for the Minecraft ComputerCraft ME API.", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "chart.js": "^4.4.7", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-basic-ssl": "^2.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/tsconfig": "^0.7.0", + "typescript": "^5.7.2", + "vite": "^6.0.7", + "vue-tsc": "^2.2.0" + } +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..627c0ef --- /dev/null +++ b/src/App.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..0a6ab1f --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,23 @@ +/** + * Resolves the backend HTTP and websocket base URLs. + * + * Both can be overridden at build time via VITE_API_BASE / VITE_WS_BASE. + * When VITE_WS_BASE is omitted it is derived from the HTTP base by swapping + * the protocol (http -> ws, https -> wss). + */ + +/** Returns the trimmed value, or undefined for empty / missing env entries. */ +function clean(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed.replace(/\/+$/, '') : undefined; +} + +const DEFAULT_API_BASE = 'https://localhost:3000'; + +export const API_BASE: string = clean(import.meta.env.VITE_API_BASE) ?? DEFAULT_API_BASE; + +export const WS_BASE: string = + clean(import.meta.env.VITE_WS_BASE) ?? API_BASE.replace(/^http/, 'ws'); + +/** Full websocket URL of the live data feed. */ +export const WS_LIVE_URL = `${WS_BASE}/api/ws/live`; diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..cfd9e9b --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,25 @@ +import { API_BASE } from './config.js'; +import type { HistoryResponse } from './types.js'; + +/** + * Fetches the historic amount data points for a single item. + * + * The item name is passed as a query parameter because item identifiers + * contain a colon (e.g. minecraft:iron_ingot). + */ +export async function fetchHistory(name: string, limit = 500): Promise { + const url = new URL(`${API_BASE}/api/me-system/history`); + url.searchParams.set('name', name); + url.searchParams.set('limit', String(limit)); + + const response = await fetch(url, { headers: { accept: 'application/json' } }); + + if (response.status === 404) { + throw new Error(`Item not found: ${name}`); + } + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + return (await response.json()) as HistoryResponse; +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..d3ca865 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,47 @@ +/* Types shared with the backend API. Kept in sync manually. */ + +export interface LiveItem { + name: string; + displayName: string | null; + mod: string; + amount: number; + updatedAt: string; +} + +export interface ModInfo { + mod: string; + itemCount: number; + total: number; +} + +export interface HistoryPoint { + amount: number; + recordedAt: string; +} + +export type SortColumn = 'amount' | 'name' | 'displayName' | 'mod' | 'updatedAt'; +export type SortOrder = 'asc' | 'desc'; + +/** Filter state that survives every live data update. */ +export interface LiveFilters { + search: string; + mod: string | null; + sort: SortColumn; + order: SortOrder; +} + +/** Messages pushed by the server over the live websocket. */ +export type WsMessage = + | { + type: 'snapshot' | 'update'; + items: LiveItem[]; + mods: ModInfo[]; + filters: LiveFilters; + } + | { type: 'error'; message: string }; + +/** Response body of GET /api/me-system/history. */ +export interface HistoryResponse { + item: LiveItem; + points: HistoryPoint[]; +} diff --git a/src/components/FilterBar.vue b/src/components/FilterBar.vue new file mode 100644 index 0000000..948d0da --- /dev/null +++ b/src/components/FilterBar.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/src/components/ItemTable.vue b/src/components/ItemTable.vue new file mode 100644 index 0000000..f1be945 --- /dev/null +++ b/src/components/ItemTable.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/src/composables/useLiveData.ts b/src/composables/useLiveData.ts new file mode 100644 index 0000000..19f55bc --- /dev/null +++ b/src/composables/useLiveData.ts @@ -0,0 +1,117 @@ +import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; +import { WS_LIVE_URL } from '../api/config.js'; +import type { LiveFilters, LiveItem, ModInfo, WsMessage } from '../api/types.js'; + +const STORAGE_KEY = 'mc-cc-live-filters'; +const RECONNECT_DELAY_MS = 2000; + +export type ConnectionStatus = 'connecting' | 'open' | 'closed'; + +function defaultFilters(): LiveFilters { + return { search: '', mod: null, sort: 'amount', order: 'desc' }; +} + +/** Loads persisted filters so the view stays filtered across reloads. */ +function loadFilters(): LiveFilters { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return defaultFilters(); + const parsed = JSON.parse(raw) as Partial; + return { ...defaultFilters(), ...parsed }; + } catch { + return defaultFilters(); + } +} + +/** + * Connects to the live data websocket and exposes reactive state. + * + * Filters are kept on the client, persisted to localStorage and sent to the + * server, which re-applies them on every push so the view stays filtered + * across data updates. + */ +export function useLiveData() { + const items = shallowRef([]); + const mods = shallowRef([]); + const filters = ref(loadFilters()); + const status = ref('connecting'); + const lastUpdate = ref(null); + + let socket: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let disposed = false; + + function send(message: unknown): void { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(message)); + } + } + + function connect(): void { + if (disposed) return; + status.value = 'connecting'; + + socket = new WebSocket(WS_LIVE_URL); + + socket.onopen = () => { + status.value = 'open'; + // Re-send the persisted filters so the server applies them immediately. + send({ type: 'setFilters', filters: filters.value }); + }; + + socket.onmessage = (event: MessageEvent) => { + let message: WsMessage; + try { + message = JSON.parse(event.data) as WsMessage; + } catch { + return; + } + if (message.type === 'snapshot' || message.type === 'update') { + items.value = message.items; + mods.value = message.mods; + lastUpdate.value = new Date().toISOString(); + } + }; + + socket.onclose = () => { + status.value = 'closed'; + scheduleReconnect(); + }; + + socket.onerror = () => { + socket?.close(); + }; + } + + function scheduleReconnect(): void { + if (disposed || reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, RECONNECT_DELAY_MS); + } + + /** Updates the active filters, persists them and notifies the server. */ + function updateFilters(next: Partial): void { + filters.value = { ...filters.value, ...next }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(filters.value)); + } catch { + /* ignore storage failures (private mode, quota, ...) */ + } + send({ type: 'setFilters', filters: filters.value }); + } + + onMounted(connect); + + onUnmounted(() => { + disposed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + if (socket) { + socket.onclose = null; + socket.close(); + } + }); + + return { items, mods, filters, status, lastUpdate, updateFilters }; +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..1c2fb74 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,18 @@ +/// + +interface ImportMetaEnv { + /** Base URL of the backend HTTP API, e.g. https://localhost:3000 */ + readonly VITE_API_BASE?: string; + /** Base URL of the backend websocket, e.g. wss://localhost:3000 */ + readonly VITE_WS_BASE?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..f801419 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import { router } from './router/index.js'; +import './style.css'; + +createApp(App).use(router).mount('#app'); diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..3f0f57f --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,23 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; +import LiveView from '../views/LiveView.vue'; + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'live', + component: LiveView, + }, + { + path: '/item/:name', + name: 'item', + // Item names contain a colon (e.g. minecraft:iron_ingot); the router + // matches them verbatim and passes the raw value through as a prop. + component: () => import('../views/ItemHistoryView.vue'), + props: true, + }, +]; + +export const router = createRouter({ + history: createWebHistory(), + routes, +}); diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..108f785 --- /dev/null +++ b/src/style.css @@ -0,0 +1,138 @@ +/* ------------------------------------------------------------------ */ +/* Theme tokens */ +/* ------------------------------------------------------------------ */ +:root { + --bg: #0a0e13; + --bg-panel: #121a22; + --bg-panel-2: #16212b; + --border: #243140; + --border-bright: #34465a; + --text: #c4d2dc; + --text-dim: #6b7d8c; + --accent: #2fd4c4; + --accent-soft: rgba(47, 212, 196, 0.12); + --accent-2: #f0a500; + --danger: #e5484d; + --positive: #46c93a; + + --font-display: 'Chakra Petch', sans-serif; + --font-mono: 'JetBrains Mono', monospace; + + --radius: 8px; + --shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + background-color: var(--bg); + background-image: + linear-gradient(rgba(47, 212, 196, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(47, 212, 196, 0.025) 1px, transparent 1px); + background-size: 36px 36px; + color: var(--text); + font-family: var(--font-display); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +#app { + min-height: 100%; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +h1, +h2, +h3 { + font-weight: 600; + letter-spacing: 0.02em; + margin: 0; +} + +button, +input, +select { + font-family: var(--font-display); + font-size: 14px; + color: var(--text); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--bg); +} + +::-webkit-scrollbar-thumb { + background: var(--border-bright); + border-radius: 5px; +} + +/* ------------------------------------------------------------------ */ +/* Shared building blocks */ +/* ------------------------------------------------------------------ */ +.panel { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.mono { + font-family: var(--font-mono); +} + +.container { + max-width: 1100px; + margin: 0 auto; + padding: 0 20px; +} + +.field { + background: var(--bg-panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 9px 12px; + outline: none; + transition: border-color 0.15s ease; +} + +.field:focus { + border-color: var(--accent); +} + +.btn { + background: var(--bg-panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 9px 14px; + cursor: pointer; + transition: + border-color 0.15s ease, + color 0.15s ease; +} + +.btn:hover { + border-color: var(--accent); + color: var(--accent); +} diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..19e9fd6 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,75 @@ +/* ------------------------------------------------------------------ */ +/* Number formatting */ +/* ------------------------------------------------------------------ */ + +const UNITS: { value: number; suffix: string }[] = [ + { value: 1e12, suffix: 'T' }, + { value: 1e9, suffix: 'B' }, + { value: 1e6, suffix: 'M' }, + { value: 1e3, suffix: 'k' }, +]; + +/** + * Abbreviates large numbers for compact display. + * + * 12000 -> "12k" + * 43452542 -> "43.45M" + * 999 -> "999" + */ +export function abbreviateNumber(input: number): string { + if (!Number.isFinite(input)) return '0'; + + const sign = input < 0 ? '-' : ''; + const abs = Math.abs(input); + + for (const { value, suffix } of UNITS) { + if (abs >= value) { + const scaled = abs / value; + // Two decimals, then strip any trailing zeros and a dangling dot. + const text = scaled.toFixed(2).replace(/\.?0+$/, ''); + return `${sign}${text}${suffix}`; + } + } + + return `${sign}${abs}`; +} + +/** Full, thousands-separated representation, used for tooltips / titles. */ +export function fullNumber(input: number): string { + return input.toLocaleString('en-US'); +} + +/* ------------------------------------------------------------------ */ +/* Date formatting */ +/* ------------------------------------------------------------------ */ + +/** Human-readable absolute timestamp. */ +export function formatTimestamp(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleString('en-GB', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** Short relative time, e.g. "12s ago", "4m ago", "2h ago". */ +export function formatRelative(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return '—'; + + const seconds = Math.round((Date.now() - date.getTime()) / 1000); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.round(hours / 24); + return `${days}d ago`; +} diff --git a/src/views/ItemHistoryView.vue b/src/views/ItemHistoryView.vue new file mode 100644 index 0000000..549086b --- /dev/null +++ b/src/views/ItemHistoryView.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/src/views/LiveView.vue b/src/views/LiveView.vue new file mode 100644 index 0000000..1667a87 --- /dev/null +++ b/src/views/LiveView.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..76ad78c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c599b05 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import basicSsl from '@vitejs/plugin-basic-ssl'; + +// `basicSsl` generates a self-signed certificate so the dev server and +// `vite preview` both serve over HTTPS out of the box. +export default defineConfig({ + plugins: [vue(), basicSsl()], + server: { + host: true, + port: 5173, + }, + preview: { + host: true, + port: 4173, + }, +});