Initial boilerplate scaffold for continuum-app
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# --- apps/web (Nuxt) runtime config ---
|
||||||
|
# Public (exposed to the client bundle)
|
||||||
|
NUXT_PUBLIC_API_BASE=https://api.continuum.local
|
||||||
|
NUXT_PUBLIC_WS_BASE=wss://api.continuum.local
|
||||||
|
NUXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co
|
||||||
|
NUXT_PUBLIC_SUPABASE_ANON_KEY=replace-with-anon-key
|
||||||
|
NUXT_PUBLIC_GO2RTC_STREAM_BASE=https://edge.continuum.local/streams
|
||||||
|
|
||||||
|
# Desktop (Tauri) build-time
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY=
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tauri
|
||||||
|
apps/desktop/src-tauri/target/
|
||||||
|
apps/desktop/src-tauri/gen/
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store/
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# continuum-app
|
||||||
|
|
||||||
|
Operator console for the Continuum print farm platform. A single Nuxt 3 SPA
|
||||||
|
(`apps/web`) shared between the browser and a Tauri 2 desktop shell
|
||||||
|
(`apps/desktop`), plus a shared component library (`packages/ui`).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
web/ Nuxt 3 SPA (ssr: false) — Supabase auth, Pinia telemetry store
|
||||||
|
desktop/ Tauri 2 shell wrapping apps/web's static build
|
||||||
|
packages/
|
||||||
|
ui/ Shared Vue components: PrinterCard, TemperatureGraph, WebRTCStreamView
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
cp .env.example apps/web/.env
|
||||||
|
pnpm dev:web # browser dev server on :3000
|
||||||
|
pnpm dev:desktop # Tauri dev shell (requires Rust toolchain)
|
||||||
|
```
|
||||||
|
|
||||||
|
`apps/web` builds as a static SPA so the exact same `apps/web/.output/public`
|
||||||
|
bundle is served in the browser and embedded into the Tauri desktop app.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@continuum/desktop",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"tauri": "tauri",
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"build": "tauri build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.1.1",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.0.3",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "continuum-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Continuum operator console desktop shell"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2.1", features = ["tray-icon"] }
|
||||||
|
tauri-plugin-fs = "2.0"
|
||||||
|
tauri-plugin-shell = "2.0"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "continuum_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "continuum-desktop"
|
||||||
|
path = "src/main.rs"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capabilities granted to the main Continuum window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"fs:default",
|
||||||
|
"fs:allow-read-file",
|
||||||
|
"fs:allow-write-file",
|
||||||
|
"fs:scope-appdata",
|
||||||
|
"shell:allow-open"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Prevents an additional console window on Windows in release builds.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::TrayIconBuilder,
|
||||||
|
Manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.setup(|app| {
|
||||||
|
let show = MenuItem::with_id(app, "show", "Show Continuum", true, None::<&str>)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &quit])?;
|
||||||
|
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.menu(&menu)
|
||||||
|
.tooltip("Continuum — print farm console")
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"show" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running the Continuum desktop shell");
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Continuum",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.continuum3d.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm --filter @continuum/web dev",
|
||||||
|
"beforeBuildCommand": "pnpm --filter @continuum/web generate",
|
||||||
|
"devUrl": "http://localhost:3000",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Continuum",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 960,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
},
|
||||||
|
"trayIcon": {
|
||||||
|
"iconPath": "icons/icon.png",
|
||||||
|
"iconAsTemplate": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"fs": {
|
||||||
|
"scope": ["$APPDATA/continuum/**", "$DOWNLOAD/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useTelemetryStore } from "~/stores/telemetry";
|
||||||
|
|
||||||
|
const telemetry = useTelemetryStore();
|
||||||
|
const user = useSupabaseUser();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
telemetry.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
telemetry.disconnect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="app-shell__header">
|
||||||
|
<span class="app-shell__brand">Continuum</span>
|
||||||
|
<span class="app-shell__status" :class="telemetry.connected ? 'is-online' : 'is-offline'">
|
||||||
|
{{ telemetry.connected ? "Live" : "Reconnecting…" }}
|
||||||
|
</span>
|
||||||
|
<span v-if="user" class="app-shell__user">{{ user.email }}</span>
|
||||||
|
</header>
|
||||||
|
<main class="app-shell__main">
|
||||||
|
<NuxtPage />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #0b0d12;
|
||||||
|
color: #e6e8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid #1f232c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__status.is-online {
|
||||||
|
color: #35d07f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__status.is-offline {
|
||||||
|
color: #f2a33c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__user {
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__main {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: "2026-01-01",
|
||||||
|
|
||||||
|
// Disabled so the exact same static bundle can be served from the browser
|
||||||
|
// and embedded verbatim into the Tauri desktop shell.
|
||||||
|
ssr: false,
|
||||||
|
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
modules: ["@nuxtjs/supabase", "@pinia/nuxt"],
|
||||||
|
|
||||||
|
supabase: {
|
||||||
|
redirect: true,
|
||||||
|
redirectOptions: {
|
||||||
|
login: "/login",
|
||||||
|
callback: "/confirm",
|
||||||
|
exclude: ["/login"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001",
|
||||||
|
wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://localhost:3001",
|
||||||
|
go2rtcStreamBase: process.env.NUXT_PUBLIC_GO2RTC_STREAM_BASE || "http://localhost:1984",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// apps/desktop's src-tauri config points at this static output directory.
|
||||||
|
nitro: {
|
||||||
|
output: {
|
||||||
|
publicDir: "../desktop/dist",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
// Tauri needs a fixed, predictable dev server port.
|
||||||
|
server: {
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
typescript: {
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@continuum/web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"typecheck": "nuxt typecheck",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@continuum/ui": "workspace:*",
|
||||||
|
"@nuxtjs/supabase": "^1.4.0",
|
||||||
|
"@pinia/nuxt": "^0.5.5",
|
||||||
|
"nuxt": "^3.13.0",
|
||||||
|
"pinia": "^2.2.2",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { PrinterCard } from "@continuum/ui";
|
||||||
|
import { useTelemetryStore } from "~/stores/telemetry";
|
||||||
|
|
||||||
|
const telemetry = useTelemetryStore();
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
|
||||||
|
const { data: printers } = await useFetch(`${config.public.apiBase}/printers`, {
|
||||||
|
headers: useRequestHeaders(["cookie"]),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fleet">
|
||||||
|
<h1>Fleet overview</h1>
|
||||||
|
<div class="fleet__grid">
|
||||||
|
<PrinterCard
|
||||||
|
v-for="printer in printers ?? []"
|
||||||
|
:key="printer.id"
|
||||||
|
:printer="printer"
|
||||||
|
:live="telemetry.printers[printer.id]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="!printers?.length">No printers registered yet.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fleet__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const email = ref("");
|
||||||
|
const sent = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const client = useSupabaseClient();
|
||||||
|
|
||||||
|
async function sendMagicLink() {
|
||||||
|
error.value = null;
|
||||||
|
const { error: authError } = await client.auth.signInWithOtp({ email: email.value });
|
||||||
|
if (authError) {
|
||||||
|
error.value = authError.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sent.value = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login">
|
||||||
|
<h1>Sign in to Continuum</h1>
|
||||||
|
<form v-if="!sent" @submit.prevent="sendMagicLink">
|
||||||
|
<input v-model="email" type="email" placeholder="you@farm.com" required />
|
||||||
|
<button type="submit">Send magic link</button>
|
||||||
|
</form>
|
||||||
|
<p v-else>Check your inbox for a sign-in link.</p>
|
||||||
|
<p v-if="error" class="login__error">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login {
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 4rem auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__error {
|
||||||
|
color: #f2604c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export interface PrinterTelemetry {
|
||||||
|
printerId: string;
|
||||||
|
farmId: string;
|
||||||
|
state: "idle" | "printing" | "paused" | "error" | "offline";
|
||||||
|
nozzleTempC: number;
|
||||||
|
bedTempC: number;
|
||||||
|
progressPct: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TelemetryState {
|
||||||
|
socket: WebSocket | null;
|
||||||
|
connected: boolean;
|
||||||
|
printers: Record<string, PrinterTelemetry>;
|
||||||
|
reconnectAttempt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTelemetryStore = defineStore("telemetry", {
|
||||||
|
state: (): TelemetryState => ({
|
||||||
|
socket: null,
|
||||||
|
connected: false,
|
||||||
|
printers: {},
|
||||||
|
reconnectAttempt: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
printerList: (state) => Object.values(state.printers),
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
connect() {
|
||||||
|
if (this.socket) return;
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const client = useSupabaseClient();
|
||||||
|
|
||||||
|
client.auth.getSession().then(({ data }) => {
|
||||||
|
const token = data.session?.access_token ?? "";
|
||||||
|
const url = `${config.public.wsBase}/ws/telemetry?token=${encodeURIComponent(token)}`;
|
||||||
|
this.open(url);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
open(url: string) {
|
||||||
|
const socket = new WebSocket(url);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
this.connected = true;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.data) as { type: string; data: PrinterTelemetry };
|
||||||
|
if (payload.type === "printer.telemetry") {
|
||||||
|
this.printers[payload.data.printerId] = payload.data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed frames rather than crashing the UI.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.socket = null;
|
||||||
|
const delay = Math.min(30_000, 1_000 * 2 ** this.reconnectAttempt);
|
||||||
|
this.reconnectAttempt += 1;
|
||||||
|
setTimeout(() => this.connect(), delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.socket = socket;
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.socket?.close();
|
||||||
|
this.socket = null;
|
||||||
|
this.connected = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "continuum-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Continuum print farm operator console — Nuxt SPA + Tauri 2 desktop shell",
|
||||||
|
"packageManager": "pnpm@9.9.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev:web": "pnpm --filter @continuum/web dev",
|
||||||
|
"dev:desktop": "pnpm --filter @continuum/desktop tauri dev",
|
||||||
|
"build:web": "pnpm --filter @continuum/web build",
|
||||||
|
"build:desktop": "pnpm --filter @continuum/desktop tauri build",
|
||||||
|
"lint": "pnpm -r lint",
|
||||||
|
"typecheck": "pnpm -r typecheck"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@continuum/ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vue-tsc": "^2.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import TemperatureGraph from "./TemperatureGraph.vue";
|
||||||
|
|
||||||
|
interface Printer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
model: string;
|
||||||
|
farmId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveTelemetry {
|
||||||
|
state: "idle" | "printing" | "paused" | "error" | "offline";
|
||||||
|
nozzleTempC: number;
|
||||||
|
bedTempC: number;
|
||||||
|
progressPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
printer: Printer;
|
||||||
|
live?: LiveTelemetry;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const state = computed(() => props.live?.state ?? "offline");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="printer-card" :class="`is-${state}`">
|
||||||
|
<header>
|
||||||
|
<h3>{{ printer.name }}</h3>
|
||||||
|
<span class="printer-card__model">{{ printer.model }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="printer-card__state">{{ state }}</p>
|
||||||
|
|
||||||
|
<TemperatureGraph
|
||||||
|
v-if="live"
|
||||||
|
:nozzle-temp-c="live.nozzleTempC"
|
||||||
|
:bed-temp-c="live.bedTempC"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<progress v-if="live" :value="live.progressPct" max="100" />
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.printer-card {
|
||||||
|
border: 1px solid #1f232c;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #12151c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.printer-card__model {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.printer-card.is-printing .printer-card__state {
|
||||||
|
color: #35d07f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.printer-card.is-error .printer-card__state {
|
||||||
|
color: #f2604c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.printer-card.is-offline .printer-card__state {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nozzleTempC: number;
|
||||||
|
bedTempC: number;
|
||||||
|
maxSamples?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const MAX_SAMPLES = props.maxSamples ?? 60;
|
||||||
|
const nozzleHistory = ref<number[]>([]);
|
||||||
|
const bedHistory = ref<number[]>([]);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.nozzleTempC, props.bedTempC] as const,
|
||||||
|
([nozzle, bed]) => {
|
||||||
|
nozzleHistory.value = [...nozzleHistory.value, nozzle].slice(-MAX_SAMPLES);
|
||||||
|
bedHistory.value = [...bedHistory.value, bed].slice(-MAX_SAMPLES);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function toPoints(history: number[], height: number) {
|
||||||
|
if (history.length === 0) return "";
|
||||||
|
const max = Math.max(...history, 1);
|
||||||
|
const step = 100 / Math.max(history.length - 1, 1);
|
||||||
|
return history
|
||||||
|
.map((value, index) => `${index * step},${height - (value / max) * height}`)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nozzlePoints = computed(() => toPoints(nozzleHistory.value, 40));
|
||||||
|
const bedPoints = computed(() => toPoints(bedHistory.value, 40));
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
nozzleHistory.value = [];
|
||||||
|
bedHistory.value = [];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="temp-graph">
|
||||||
|
<svg viewBox="0 0 100 40" preserveAspectRatio="none">
|
||||||
|
<polyline class="temp-graph__nozzle" :points="nozzlePoints" fill="none" />
|
||||||
|
<polyline class="temp-graph__bed" :points="bedPoints" fill="none" />
|
||||||
|
</svg>
|
||||||
|
<div class="temp-graph__labels">
|
||||||
|
<span>Nozzle {{ nozzleTempC.toFixed(0) }}°C</span>
|
||||||
|
<span>Bed {{ bedTempC.toFixed(0) }}°C</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.temp-graph svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-graph__nozzle {
|
||||||
|
stroke: #f2a33c;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-graph__bed {
|
||||||
|
stroke: #4c9df2;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.temp-graph__labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
streamBaseUrl: string;
|
||||||
|
cameraId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const videoEl = ref<HTMLVideoElement | null>(null);
|
||||||
|
const pc = ref<RTCPeerConnection | null>(null);
|
||||||
|
const status = ref<"connecting" | "connected" | "failed">("connecting");
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
status.value = "connecting";
|
||||||
|
pc.value?.close();
|
||||||
|
|
||||||
|
const connection = new RTCPeerConnection();
|
||||||
|
pc.value = connection;
|
||||||
|
|
||||||
|
connection.addTransceiver("video", { direction: "recvonly" });
|
||||||
|
connection.ontrack = (event) => {
|
||||||
|
if (videoEl.value) videoEl.value.srcObject = event.streams[0];
|
||||||
|
};
|
||||||
|
connection.onconnectionstatechange = () => {
|
||||||
|
if (connection.connectionState === "connected") status.value = "connected";
|
||||||
|
if (connection.connectionState === "failed") status.value = "failed";
|
||||||
|
};
|
||||||
|
|
||||||
|
const offer = await connection.createOffer();
|
||||||
|
await connection.setLocalDescription(offer);
|
||||||
|
|
||||||
|
// go2rtc's WebRTC WHEP-style signaling endpoint.
|
||||||
|
const response = await fetch(`${props.streamBaseUrl}/api/webrtc?src=${props.cameraId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/sdp" },
|
||||||
|
body: offer.sdp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const answerSdp = await response.text();
|
||||||
|
await connection.setRemoteDescription({ type: "answer", sdp: answerSdp });
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.streamBaseUrl, props.cameraId], connect, { immediate: true });
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
pc.value?.close();
|
||||||
|
pc.value = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="webrtc-view">
|
||||||
|
<video ref="videoEl" autoplay playsinline muted />
|
||||||
|
<span class="webrtc-view__status" :class="`is-${status}`">{{ status }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.webrtc-view {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webrtc-view video {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webrtc-view__status {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
right: 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webrtc-view__status.is-connected {
|
||||||
|
color: #35d07f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webrtc-view__status.is-failed {
|
||||||
|
color: #f2604c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as PrinterCard } from "./components/PrinterCard.vue";
|
||||||
|
export { default as TemperatureGraph } from "./components/TemperatureGraph.vue";
|
||||||
|
export { default as WebRTCStreamView } from "./components/WebRTCStreamView.vue";
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"declaration": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
Reference in New Issue
Block a user