feat(server): serve dashboard SPA with deep-link fallback; remove v1 portal
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m26s
Build and Test / Build Agent (Windows) (push) Successful in 7m17s
Build and Test / Security Audit (push) Successful in 4m29s
Build and Test / Build Summary (push) Has been skipped

Axum now serves the v2 React/Vite dashboard SPA at / with a client-side
routing fallback, and the dead v1 HTML portal is removed (nothing was
live on the server to preserve).

- SPA served from server/static/app via ServeDir with a fallback to
  index.html, so deep links (/machines, /sessions) resolve to the SPA.
- /api/*rest and /ws/*rest return JSON 404 so unrouted API/WS paths never
  leak index.html to clients; real /api, /ws, /health, /metrics, and the
  /downloads nest keep precedence (matchit static-over-wildcard).
- Path-aware Cache-Control: hashed /assets immutable, index.html no-cache.
- Vite builds to server/static/app (base /); the artifact is gitignored
  and rebuilt at deploy time (npm ci && npm run build).
- Removed v1 portal files (login/dashboard/users/index/viewer .html) and
  their dead serve_* handlers; the SPA owns /, /login, /dashboard, /users.

Verified locally: server boots, / and deep links serve the SPA, unknown
/api path returns JSON 404 (not HTML), /health and /downloads intact.
cargo build + clippy -D warnings green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 13:44:13 -07:00
parent 6ecb937eb6
commit 67f3722b3c
10 changed files with 159 additions and 3436 deletions

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ vendor/
# Generated files # Generated files
*.generated.* *.generated.*
# Built SPA (Vite build output served by the server; rebuilt from dashboard/)
/server/static/app/

View File

@@ -89,21 +89,50 @@ same-origin requests reach the backend with no CORS setup.
To develop the UI against a *remote* backend instead, set `VITE_API_URL` To develop the UI against a *remote* backend instead, set `VITE_API_URL`
(see `.env.example`). (see `.env.example`).
## Production serving — follow-up (NOT wired in this pass) ## Production serving — WIRED
The build uses `base: "./"` so emitted assets use relative paths. Production The SPA is served by the GC Axum server from the server root. No manual copy
serving means copying `dist/` into the GC server's static directory and adding a step: `vite.config.ts` sets `build.outDir` to `../server/static/app/`, so the
catch-all route that returns `index.html` for non-API, non-asset paths (so deep build lands exactly where the server serves it.
links like `/machines` survive a hard reload under the `BrowserRouter`).
That Rust-side wiring is a **deploy concern** and is intentionally left for a ### Build & deploy flow
later step:
1. Copy `dist/``server/static/` (or serve `dist/` directly). ```bash
2. Add an Axum fallback route serving `index.html` for unmatched GET paths, # from dashboard/
*after* the `/api/*`, `/ws/*`, and static-asset routes. npm run build # tsc -b && vite build -> ../server/static/app/
3. If the dashboard is mounted under a sub-path rather than the server root, ```
switch Vite `base` to that path and pass the same `basename` to
That single command refreshes the served SPA. `emptyOutDir` clears only
`server/static/app/` (the dedicated SPA subdir), so the v1 portal files in the
static root are never touched.
### How the server serves it (`server/src/main.rs`)
- `base` is **`/`** (absolute asset paths). The SPA uses `BrowserRouter`, so a
hard reload of a deep link (`/machines`) must still load `/assets/*`; relative
(`./`) paths would resolve against the deep-link path and 404. Absolute is
required.
- The Router's `fallback_service` is `ServeDir::new("static/app")` with
`.fallback(ServeFile::new("static/app/index.html"))`. Real files under
`/assets/*` are served from disk; any other unmatched path returns
`index.html` (HTTP **200**) so React Router resolves the route.
- **Precedence / safety:** the fallback runs only after every explicit
`/api/*`, `/ws/*`, `/health`, `/metrics` route and the `/downloads` nest. Two
catch-all routes — `/api/*rest` and `/ws/*rest` — return a JSON **404** for
unrouted API/WS paths, so the SPA fallback never answers an API/WS path with
HTML (which would break this client's error-envelope parsing).
- **Caching:** `/assets/*` (content-hashed) → `immutable`, one year;
`index.html` and everything else → `no-cache, must-revalidate`.
### Build output in git
`server/static/app/` is a build artifact. Whether to commit it or `.gitignore`
it depends on the deploy model (server-side `npm run build` vs shipping the
repo's static dir). Decide at commit time. The old `dashboard/dist/` path is no
longer used.
### Sub-path mounting (not used)
The dashboard is mounted at the server root. If it is ever moved under a
sub-path, switch Vite `base` to that path and pass the same `basename` to
`<BrowserRouter>`. `<BrowserRouter>`.
No server/Rust changes were made in this pass.

View File

@@ -5,14 +5,23 @@ import react from "@vitejs/plugin-react";
// forwarded to the Rust server on :3002 so `npm run dev` works against a real // forwarded to the Rust server on :3002 so `npm run dev` works against a real
// backend without CORS gymnastics. // backend without CORS gymnastics.
// //
// `base` is "./" so the built assets reference relative paths — production // PRODUCTION SERVING (wired in the GC server):
// serving copies `dist/` into the server's static dir and a catch-all route // - `base` is "/" (absolute asset paths). The SPA is served from the server
// serves index.html. Wiring that catch-all in the Rust server is a DEPLOY // root and uses `BrowserRouter`, so a hard reload of a deep link such as
// concern (see README), not done in this pass. // `/machines` must still resolve `/assets/*` correctly. Relative ("./")
// asset paths would break here: the browser would resolve them against the
// deep-link path (`/machines/assets/...`) and 404. Absolute "/" is required.
// - `build.outDir` points straight into the server's static tree at
// `server/static/app/`, so `npm run build` lands the SPA exactly where the
// Axum `fallback_service` serves it — no manual copy step at deploy time.
// - `emptyOutDir` is true, which is SAFE because the target is the dedicated
// `app/` SUBDIR. It wipes only `server/static/app/`, never the static root,
// so the v1 portal files (login.html, dashboard.html, viewer.html,
// downloads/) are untouched.
const GC_SERVER = "http://localhost:3002"; const GC_SERVER = "http://localhost:3002";
export default defineConfig({ export default defineConfig({
base: "./", base: "/",
plugins: [react()], plugins: [react()],
server: { server: {
port: 5273, port: 5273,
@@ -29,7 +38,11 @@ export default defineConfig({
}, },
}, },
build: { build: {
outDir: "dist", // Emit directly into the server's static tree. The Axum server serves this
// directory as its SPA fallback (see server/src/main.rs). Dedicated subdir,
// so emptyOutDir only clears the SPA build — not the v1 portal files.
outDir: "../server/static/app",
emptyOutDir: true,
sourcemap: true, sourcemap: true,
}, },
}); });

View File

@@ -24,20 +24,36 @@ use axum::{
extract::{ConnectInfo, Json, Path, Query, Request, State}, extract::{ConnectInfo, Json, Path, Query, Request, State},
http::StatusCode, http::StatusCode,
middleware::{self as axum_middleware, Next}, middleware::{self as axum_middleware, Next},
response::{Html, IntoResponse}, response::IntoResponse,
routing::{delete, get, post, put}, routing::{any, delete, get, post, put},
Router, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir; use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing::{info, Level}; use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber; use tracing_subscriber::FmtSubscriber;
use auth::{generate_random_password, hash_password, AuthenticatedUser, JwtConfig, TokenBlacklist}; use auth::{generate_random_password, hash_password, AuthenticatedUser, JwtConfig, TokenBlacklist};
/// Root of the static asset tree, relative to the server's working directory.
/// Holds the agent `downloads/` tree AND the v2 SPA build under `app/`.
const STATIC_DIR: &str = "static";
/// Directory the React/Vite SPA is built into (`dashboard/` Vite `build.outDir`
/// points here). The Axum `fallback_service` serves this tree at the server
/// root, so `npm run build` lands the SPA exactly where it is served — no copy
/// step. A dedicated subdir so the Vite build's `emptyOutDir` clears only the
/// SPA, never the agent downloads tree in the static root.
const SPA_DIR: &str = "static/app";
/// The SPA entry document. Returned (with 200) for any unmatched, non-API,
/// non-WS, non-asset GET so `BrowserRouter` deep links (`/machines`,
/// `/sessions`, `/login`) survive a hard reload.
const SPA_INDEX: &str = "static/app/index.html";
use metrics::SharedMetrics; use metrics::SharedMetrics;
use prometheus_client::registry::Registry; use prometheus_client::registry::Registry;
use support_codes::{CodeValidation, CreateCodeRequest, SupportCode, SupportCodeManager}; use support_codes::{CodeValidation, CreateCodeRequest, SupportCode, SupportCodeManager};
@@ -413,15 +429,43 @@ async fn main() -> Result<()> {
get(api::downloads::download_support), get(api::downloads::download_support),
) )
.route("/api/download/agent", get(api::downloads::download_agent)) .route("/api/download/agent", get(api::downloads::download_agent))
// HTML page routes (clean URLs) // Namespace 404 guards. These wildcard routes catch any /api/* or /ws/*
.route("/login", get(serve_login)) // path that no explicit route above matched, returning a JSON 404 so the
.route("/dashboard", get(serve_dashboard)) // SPA fallback_service never answers an API/WS path with index.html. They
.route("/users", get(serve_users)) // are intentionally the LEAST specific routes in each namespace: matchit
// (axum 0.7) prefers a static segment over a `*` capture, so every real
// route above still wins. `any(...)` covers every method (a bad WS path
// is a GET, but POST/PUT/etc. to a dead /api/* path must 404 too, not 405).
.route("/api/*rest", any(api_not_found))
.route("/ws/*rest", any(api_not_found))
// Public agent download tree (e.g. /downloads/guruconnect.exe). Mounted
// explicitly so it keeps working after the v2 SPA takes over the root
// fallback below — CLAUDE.md documents this as the public download URL.
// `nest_service` is matched BEFORE `fallback_service`, so these binaries
// are served from disk and never fall through to the SPA index.html.
.nest_service("/downloads", ServeDir::new(format!("{STATIC_DIR}/downloads")))
// NOTE: there are intentionally no /login, /dashboard, /users routes.
// The v2 SPA (BrowserRouter) owns those paths and resolves them via the
// fallback_service below; registering server-side handlers for them would
// shadow the SPA on a hard reload.
// State and middleware // State and middleware
.with_state(state.clone()) .with_state(state.clone())
.layer(axum_middleware::from_fn_with_state(state, auth_layer)) .layer(axum_middleware::from_fn_with_state(state, auth_layer))
// Serve static files for portal (fallback) // SPA fallback: serve the React/Vite build from SPA_DIR and, for any
.fallback_service(ServeDir::new("static").append_index_html_on_directories(true)) // unmatched path, return the SPA index.html WITH 200 (via `.fallback`,
// not `.not_found_service` which would force a 404) so BrowserRouter
// deep links resolve. This is the Router's `fallback_service`, so it runs
// ONLY after every explicit /api/*, /ws/*, /health, /metrics route and
// the /downloads nest fail to match. An unknown /api/... path therefore
// never reaches here — it hits the per-router 404 and returns the normal
// (non-HTML) 404 the typed client expects. Real assets under /assets/*
// are served from disk by ServeDir with correct content-types; only
// genuinely missing files fall through to index.html.
.fallback_service(
ServeDir::new(SPA_DIR)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(SPA_INDEX)),
)
// Middleware // Middleware
.layer(axum_middleware::from_fn(middleware::add_security_headers)) // SEC-7 & SEC-12 .layer(axum_middleware::from_fn(middleware::add_security_headers)) // SEC-7 & SEC-12
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
@@ -474,6 +518,23 @@ async fn health() -> &'static str {
"OK" "OK"
} }
/// Explicit 404 for unmatched paths under the `/api` and `/ws` namespaces.
///
/// CRITICAL: without these catch-all routes, an unknown `/api/...` or `/ws/...`
/// path would fall through to the SPA `fallback_service` and be answered with
/// `index.html` (HTTP 200, text/html). That would mask real 404s and break the
/// dashboard's typed client, which parses a JSON error envelope from API 404s.
/// These routes are LESS specific than every real `/api/...` / `/ws/...` route
/// (matchit matches a static segment before a `*` capture), so they only catch
/// genuinely-unrouted API/WS paths and return a proper JSON 404 — never HTML.
async fn api_not_found() -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
[(axum::http::header::CONTENT_TYPE, "application/json")],
r#"{"error":"Not Found","status_code":404}"#,
)
}
/// Prometheus metrics endpoint /// Prometheus metrics endpoint
async fn prometheus_metrics(State(state): State<AppState>) -> String { async fn prometheus_metrics(State(state): State<AppState>) -> String {
use prometheus_client::encoding::text::encode; use prometheus_client::encoding::text::encode;
@@ -849,24 +910,3 @@ async fn trigger_machine_update(
} }
} }
// Static page handlers
async fn serve_login() -> impl IntoResponse {
match tokio::fs::read_to_string("static/login.html").await {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}
async fn serve_dashboard() -> impl IntoResponse {
match tokio::fs::read_to_string("static/dashboard.html").await {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}
async fn serve_users() -> impl IntoResponse {
match tokio::fs::read_to_string("static/users.html").await {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}

View File

@@ -7,9 +7,33 @@ use axum::{extract::Request, middleware::Next, response::Response};
/// Add security headers to all responses /// Add security headers to all responses
pub async fn add_security_headers(request: Request, next: Next) -> Response { pub async fn add_security_headers(request: Request, next: Next) -> Response {
// Capture the path before the request is consumed downstream, so we can pick
// the right Cache-Control for the SPA: hashed assets are immutable and can be
// cached forever; everything else (index.html for `/`, deep links, and API
// JSON) must revalidate so a fresh deploy is picked up immediately.
let path = request.uri().path().to_owned();
let mut response = next.run(request).await; let mut response = next.run(request).await;
let headers = response.headers_mut(); let headers = response.headers_mut();
// Cache-Control. Vite emits content-hashed filenames under `/assets/`, so
// those are safe to cache aggressively (`immutable`); a new build changes the
// hash and therefore the URL. The SPA shell (index.html), served for `/` and
// every BrowserRouter deep link, must NOT be cached stale or clients would
// load an old shell pointing at assets that no longer exist after a deploy —
// hence `no-cache, must-revalidate`. Only set this when a downstream handler
// hasn't already chosen its own caching policy.
if !headers.contains_key(axum::http::header::CACHE_CONTROL) {
let cache_value = if path.starts_with("/assets/") {
"public, max-age=31536000, immutable"
} else {
"no-cache, must-revalidate"
};
if let Ok(v) = cache_value.parse() {
headers.insert(axum::http::header::CACHE_CONTROL, v);
}
}
// SEC-7: Content Security Policy (XSS Prevention) // SEC-7: Content Security Policy (XSS Prevention)
// This CSP allows inline scripts/styles (needed for dashboard) but blocks external resources // This CSP allows inline scripts/styles (needed for dashboard) but blocks external resources
headers.insert( headers.insert(

File diff suppressed because it is too large Load Diff

View File

@@ -1,425 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GuruConnect - Remote Support</title>
<style>
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 440px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
padding: 40px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.logo {
text-align: center;
margin-bottom: 32px;
}
.logo h1 {
font-size: 28px;
font-weight: 700;
color: hsl(var(--foreground));
}
.logo p {
color: hsl(var(--muted-foreground));
margin-top: 8px;
font-size: 14px;
}
.code-form {
display: flex;
flex-direction: column;
gap: 16px;
}
label {
font-size: 14px;
font-weight: 500;
color: hsl(var(--foreground));
}
.code-input-wrapper {
position: relative;
}
.code-input {
width: 100%;
padding: 16px 20px;
font-size: 32px;
font-weight: 600;
letter-spacing: 8px;
text-align: center;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 8px;
color: hsl(var(--foreground));
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.code-input:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsla(var(--ring), 0.3);
}
.code-input::placeholder {
color: hsl(var(--muted-foreground));
letter-spacing: 4px;
}
.connect-btn {
width: 100%;
padding: 14px 24px;
font-size: 16px;
font-weight: 600;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
.connect-btn:hover {
opacity: 0.9;
}
.connect-btn:active {
transform: scale(0.98);
}
.connect-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
background: hsla(0, 70%, 50%, 0.1);
border: 1px solid hsla(0, 70%, 50%, 0.3);
color: hsl(0, 70%, 70%);
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.error-message.visible {
display: block;
}
.divider {
border-top: 1px solid hsl(var(--border));
margin: 24px 0;
}
.instructions {
display: none;
text-align: left;
}
.instructions.visible {
display: block;
}
.instructions h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: hsl(var(--foreground));
}
.instructions ol {
padding-left: 20px;
color: hsl(var(--muted-foreground));
font-size: 14px;
line-height: 1.8;
}
.instructions li {
margin-bottom: 8px;
}
.footer {
margin-top: 24px;
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 12px;
}
.footer a {
color: hsl(var(--primary));
text-decoration: none;
}
.spinner {
display: none;
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading .spinner {
display: inline-block;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<h1>GuruConnect</h1>
<p>Remote Support Portal</p>
</div>
<form class="code-form" id="codeForm">
<label for="codeInput">Enter your support code:</label>
<div class="code-input-wrapper">
<input
type="text"
id="codeInput"
class="code-input"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
autocomplete="off"
required
>
</div>
<div class="error-message" id="errorMessage"></div>
<button type="submit" class="connect-btn" id="connectBtn">
<span class="spinner"></span>
<span class="btn-text">Connect</span>
</button>
</form>
<div class="divider"></div>
<div class="instructions" id="instructions">
<h3>How to connect:</h3>
<ol id="instructionsList">
<li>Enter the 6-digit code provided by your technician</li>
<li>Click "Connect" to start the session</li>
<li>If prompted, allow the download and run the file</li>
</ol>
</div>
<div class="footer">
<p>Need help? Contact <a href="mailto:support@azcomputerguru.com">support@azcomputerguru.com</a></p>
<p style="margin-top: 12px;"><a href="/login" style="color: hsl(var(--muted-foreground)); font-size: 11px;">Technician Login</a></p>
</div>
</div>
<script>
const form = document.getElementById('codeForm');
const codeInput = document.getElementById('codeInput');
const connectBtn = document.getElementById('connectBtn');
const errorMessage = document.getElementById('errorMessage');
const instructions = document.getElementById('instructions');
const instructionsList = document.getElementById('instructionsList');
// Auto-format input (numbers only)
codeInput.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/[^0-9]/g, '').slice(0, 6);
errorMessage.classList.remove('visible');
});
// Detect browser
function detectBrowser() {
const ua = navigator.userAgent;
if (ua.includes('Edg/')) return 'edge';
if (ua.includes('Chrome/')) return 'chrome';
if (ua.includes('Firefox/')) return 'firefox';
if (ua.includes('Safari/') && !ua.includes('Chrome')) return 'safari';
return 'unknown';
}
// Browser-specific instructions
function getBrowserInstructions(browser) {
const instrs = {
chrome: [
'Click the download in the <strong>bottom-left corner</strong> of your screen',
'Click <strong>"Open"</strong> or <strong>"Keep"</strong> if prompted',
'The support session will start automatically'
],
firefox: [
'Click <strong>"Save File"</strong> in the download dialog',
'Open your <strong>Downloads folder</strong>',
'Double-click <strong>GuruConnect.exe</strong> to start'
],
edge: [
'Click <strong>"Open file"</strong> in the download notification at the top',
'If you see "Keep" button, click it first, then "Open file"',
'The support session will start automatically'
],
safari: [
'Click the <strong>download icon</strong> in the toolbar',
'Double-click the downloaded file',
'Click <strong>"Open"</strong> if macOS asks for confirmation'
],
unknown: [
'Your download should start automatically',
'Look for the file in your <strong>Downloads folder</strong>',
'Double-click the file to start the support session'
]
};
return instrs[browser] || instrs.unknown;
}
// Show browser-specific instructions
function showInstructions() {
const browser = detectBrowser();
const steps = getBrowserInstructions(browser);
instructionsList.innerHTML = steps.map(step => '<li>' + step + '</li>').join('');
instructions.classList.add('visible');
}
// Handle form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
const code = codeInput.value.trim();
if (code.length !== 6) {
showError('Please enter a 6-digit code');
return;
}
setLoading(true);
try {
// Validate code with server
const response = await fetch('/api/codes/' + code + '/validate');
const data = await response.json();
if (!data.valid) {
showError(data.error || 'Invalid code');
setLoading(false);
return;
}
// Try to launch via custom protocol
const protocolUrl = 'guruconnect://session/' + code;
// Attempt protocol launch with timeout fallback
let protocolLaunched = false;
const protocolTimeout = setTimeout(() => {
if (!protocolLaunched) {
// Protocol didn't work, trigger download
triggerDownload(code, data.session_id);
}
}, 2500);
// Try the protocol
window.location.href = protocolUrl;
// Check if we're still here after a moment
setTimeout(() => {
protocolLaunched = document.hidden;
if (protocolLaunched) {
clearTimeout(protocolTimeout);
}
}, 500);
} catch (err) {
showError('Connection error. Please try again.');
setLoading(false);
}
});
function triggerDownload(code, sessionId) {
// Show instructions
showInstructions();
setLoading(false);
connectBtn.querySelector('.btn-text').textContent = 'Download Starting...';
// Create a temporary link to download the agent
// The agent will be run with the code as argument
const downloadLink = document.createElement('a');
downloadLink.href = '/guruconnect-agent.exe';
downloadLink.download = 'GuruConnect-' + code + '.exe';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
// Show instructions with the code reminder
setTimeout(() => {
connectBtn.querySelector('.btn-text').textContent = 'Run the Downloaded File';
// Update instructions to include the code
instructionsList.innerHTML = getBrowserInstructions(detectBrowser()).map(step => '<li>' + step + '</li>').join('') +
'<li><strong>Important:</strong> When prompted, enter code: <strong style="color: hsl(var(--primary)); font-size: 18px;">' + code + '</strong></li>';
}, 500);
}
function showError(message) {
errorMessage.textContent = message;
errorMessage.classList.add('visible');
}
function setLoading(loading) {
connectBtn.disabled = loading;
connectBtn.classList.toggle('loading', loading);
if (loading) {
connectBtn.querySelector('.btn-text').textContent = 'Connecting...';
} else if (!instructions.classList.contains('visible')) {
connectBtn.querySelector('.btn-text').textContent = 'Connect';
}
}
// Focus input on load
codeInput.focus();
</script>
</body>
</html>

View File

@@ -1,229 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GuruConnect - Login</title>
<style>
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 400px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
padding: 40px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.logo { text-align: center; margin-bottom: 32px; }
.logo h1 { font-size: 28px; font-weight: 700; color: hsl(var(--foreground)); }
.logo p { color: hsl(var(--muted-foreground)); margin-top: 8px; font-size: 14px; }
.login-form { display: flex; flex-direction: column; gap: 20px; }
.form-group { display: flex; flex-direction: column; gap: 8px; }
label { font-size: 14px; font-weight: 500; color: hsl(var(--foreground)); }
input[type="text"], input[type="password"] {
width: 100%;
padding: 12px 16px;
font-size: 14px;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 8px;
color: hsl(var(--foreground));
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsla(var(--ring), 0.3);
}
input::placeholder { color: hsl(var(--muted-foreground)); }
.login-btn {
width: 100%;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
margin-top: 8px;
}
.login-btn:hover { opacity: 0.9; }
.login-btn:active { transform: scale(0.98); }
.login-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error-message {
background: hsla(0, 70%, 50%, 0.1);
border: 1px solid hsla(0, 70%, 50%, 0.3);
color: hsl(0, 70%, 70%);
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: none;
}
.error-message.visible { display: block; }
.footer { margin-top: 24px; text-align: center; color: hsl(var(--muted-foreground)); font-size: 12px; }
.footer a { color: hsl(var(--primary)); text-decoration: none; }
.spinner {
display: none;
width: 18px;
height: 18px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading .spinner { display: inline-block; }
</style>
</head>
<body>
<div class="container">
<div class="logo">
<h1>GuruConnect</h1>
<p>Sign in to your account</p>
</div>
<form class="login-form" id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" placeholder="Enter your username" autocomplete="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" placeholder="Enter your password" autocomplete="current-password" required>
</div>
<div class="error-message" id="errorMessage"></div>
<button type="submit" class="login-btn" id="loginBtn">
<span class="spinner"></span>
<span class="btn-text">Sign In</span>
</button>
</form>
<div class="footer">
<p><a href="/">Back to Support Portal</a></p>
</div>
</div>
<script>
const form = document.getElementById("loginForm");
const loginBtn = document.getElementById("loginBtn");
const errorMessage = document.getElementById("errorMessage");
// Check if already logged in
const token = localStorage.getItem("guruconnect_token");
if (token) {
// Verify token is still valid
fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
}).then(res => {
if (res.ok) {
window.location.href = '/dashboard';
} else {
localStorage.removeItem('guruconnect_token');
localStorage.removeItem('guruconnect_user');
}
}).catch(() => {
localStorage.removeItem('guruconnect_token');
localStorage.removeItem('guruconnect_user');
});
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
setLoading(true);
errorMessage.classList.remove("visible");
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
showError(data.error || "Login failed");
setLoading(false);
return;
}
// Store token and user info
localStorage.setItem("guruconnect_token", data.token);
localStorage.setItem("guruconnect_user", JSON.stringify(data.user));
window.location.href = "/dashboard";
} catch (err) {
showError("Connection error. Please try again.");
setLoading(false);
}
});
function showError(message) {
errorMessage.textContent = message;
errorMessage.classList.add("visible");
}
function setLoading(loading) {
loginBtn.disabled = loading;
loginBtn.classList.toggle("loading", loading);
loginBtn.querySelector(".btn-text").textContent = loading ? "Signing in..." : "Sign In";
}
// Focus username field
document.getElementById("username").focus();
</script>
</body>
</html>

View File

@@ -1,602 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GuruConnect - User Management</title>
<style>
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--accent: 217.2 32.6% 17.5%;
--destructive: 0 62.8% 30.6%;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--card));
}
.header-left { display: flex; align-items: center; gap: 24px; }
.logo { font-size: 20px; font-weight: 700; color: hsl(var(--foreground)); }
.back-link { color: hsl(var(--muted-foreground)); text-decoration: none; font-size: 14px; }
.back-link:hover { color: hsl(var(--foreground)); }
.content { padding: 24px; max-width: 1200px; margin: 0 auto; }
.card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title { font-size: 18px; font-weight: 600; }
.card-description { color: hsl(var(--muted-foreground)); font-size: 14px; margin-top: 4px; }
.btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
.btn-primary:hover { opacity: 0.9; }
.btn-outline { background: transparent; color: hsl(var(--foreground)); border: 1px solid hsl(var(--border)); }
.btn-outline:hover { background: hsl(var(--accent)); }
.btn-danger { background: hsl(var(--destructive)); color: white; }
.btn-danger:hover { opacity: 0.9; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid hsl(var(--border)); }
th { font-size: 12px; font-weight: 600; text-transform: uppercase; color: hsl(var(--muted-foreground)); }
td { font-size: 14px; }
tr:hover { background: hsla(var(--muted), 0.3); }
.badge { display: inline-block; padding: 4px 10px; font-size: 12px; font-weight: 500; border-radius: 9999px; }
.badge-admin { background: hsla(270, 76%, 50%, 0.2); color: hsl(270, 76%, 60%); }
.badge-operator { background: hsla(45, 93%, 47%, 0.2); color: hsl(45, 93%, 55%); }
.badge-viewer { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); }
.badge-enabled { background: hsla(142, 76%, 36%, 0.2); color: hsl(142, 76%, 50%); }
.badge-disabled { background: hsla(0, 70%, 50%, 0.2); color: hsl(0, 70%, 60%); }
.empty-state { text-align: center; padding: 48px 24px; color: hsl(var(--muted-foreground)); }
.empty-state h3 { font-size: 16px; margin-bottom: 8px; color: hsl(var(--foreground)); }
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid hsl(var(--border));
}
.modal-title { font-size: 18px; font-weight: 600; }
.modal-close {
background: transparent;
border: none;
color: hsl(var(--muted-foreground));
font-size: 24px;
cursor: pointer;
padding: 4px;
}
.modal-close:hover { color: hsl(var(--foreground)); }
.modal-body { padding: 20px; }
.modal-footer { padding: 16px 20px; border-top: 1px solid hsl(var(--border)); display: flex; gap: 12px; justify-content: flex-end; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px; }
.form-group input, .form-group select {
width: 100%;
padding: 10px 14px;
font-size: 14px;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--foreground));
outline: none;
}
.form-group input:focus, .form-group select:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsla(var(--ring), 0.3);
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.permission-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: hsl(var(--muted));
border-radius: 6px;
font-size: 13px;
}
.permission-item input[type="checkbox"] {
width: auto;
}
.error-message {
background: hsla(0, 70%, 50%, 0.1);
border: 1px solid hsla(0, 70%, 50%, 0.3);
color: hsl(0, 70%, 70%);
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
margin-bottom: 16px;
display: none;
}
.error-message.visible { display: block; }
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
justify-content: center;
align-items: center;
z-index: 2000;
}
.loading-overlay.active { display: flex; }
.spinner {
width: 40px;
height: 40px;
border: 3px solid hsl(var(--muted));
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<header class="header">
<div class="header-left">
<div class="logo">GuruConnect</div>
<a href="/dashboard" class="back-link">&larr; Back to Dashboard</a>
</div>
</header>
<main class="content">
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">User Management</h2>
<p class="card-description">Create and manage user accounts</p>
</div>
<button class="btn btn-primary" onclick="openCreateModal()">Create User</button>
</div>
<div class="error-message" id="errorMessage"></div>
<table>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersTable">
<tr>
<td colspan="6">
<div class="empty-state">
<h3>Loading users...</h3>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</main>
<!-- Create/Edit User Modal -->
<div class="modal-overlay" id="userModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modalTitle">Create User</div>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="userForm">
<input type="hidden" id="userId">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" required minlength="3">
</div>
<div class="form-group" id="passwordGroup">
<label for="password">Password</label>
<input type="password" id="password" minlength="8">
<small style="color: hsl(var(--muted-foreground)); font-size: 12px;">Minimum 8 characters. Leave blank to keep existing password.</small>
</div>
<div class="form-group">
<label for="email">Email (optional)</label>
<input type="email" id="email">
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role">
<option value="viewer">Viewer - View only access</option>
<option value="operator">Operator - Can control machines</option>
<option value="admin">Admin - Full access</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="enabled" checked style="width: auto; margin-right: 8px;">
Account Enabled
</label>
</div>
<div class="form-group">
<label>Permissions</label>
<div class="permissions-grid">
<label class="permission-item">
<input type="checkbox" id="perm-view" checked>
View
</label>
<label class="permission-item">
<input type="checkbox" id="perm-control">
Control
</label>
<label class="permission-item">
<input type="checkbox" id="perm-transfer">
Transfer
</label>
<label class="permission-item">
<input type="checkbox" id="perm-manage_users">
Manage Users
</label>
<label class="permission-item">
<input type="checkbox" id="perm-manage_clients">
Manage Clients
</label>
</div>
</div>
<div class="error-message" id="formError"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveUser()">Save</button>
</div>
</div>
</div>
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
</div>
<script>
const token = localStorage.getItem("guruconnect_token");
let users = [];
let editingUser = null;
// Check auth
if (!token) {
window.location.href = "/login";
}
// Verify admin access
async function checkAdmin() {
try {
const response = await fetch("/api/auth/me", {
headers: { "Authorization": `Bearer ${token}` }
});
if (!response.ok) {
window.location.href = "/login";
return;
}
const user = await response.json();
if (user.role !== "admin") {
alert("Admin access required");
window.location.href = "/dashboard";
return;
}
loadUsers();
} catch (err) {
console.error("Auth check failed:", err);
window.location.href = "/login";
}
}
checkAdmin();
async function loadUsers() {
try {
const response = await fetch("/api/users", {
headers: { "Authorization": `Bearer ${token}` }
});
if (!response.ok) {
throw new Error("Failed to load users");
}
users = await response.json();
renderUsers();
} catch (err) {
showError(err.message);
}
}
function renderUsers() {
const tbody = document.getElementById("usersTable");
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><h3>No users found</h3></div></td></tr>';
return;
}
tbody.innerHTML = users.map(user => {
const roleClass = user.role === "admin" ? "badge-admin" :
user.role === "operator" ? "badge-operator" : "badge-viewer";
const statusClass = user.enabled ? "badge-enabled" : "badge-disabled";
const lastLogin = user.last_login ? new Date(user.last_login).toLocaleString() : "Never";
return `<tr>
<td><strong>${escapeHtml(user.username)}</strong></td>
<td>${escapeHtml(user.email || "-")}</td>
<td><span class="badge ${roleClass}">${user.role}</span></td>
<td><span class="badge ${statusClass}">${user.enabled ? "Enabled" : "Disabled"}</span></td>
<td>${lastLogin}</td>
<td>
<button class="btn btn-outline btn-sm" onclick="editUser('${user.id}')">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteUser('${user.id}', '${escapeHtml(user.username)}')" style="margin-left: 4px;">Delete</button>
</td>
</tr>`;
}).join("");
}
function openCreateModal() {
editingUser = null;
document.getElementById("modalTitle").textContent = "Create User";
document.getElementById("userForm").reset();
document.getElementById("userId").value = "";
document.getElementById("username").disabled = false;
document.getElementById("password").required = true;
document.getElementById("perm-view").checked = true;
document.getElementById("formError").classList.remove("visible");
document.getElementById("userModal").classList.add("active");
}
function editUser(id) {
editingUser = users.find(u => u.id === id);
if (!editingUser) return;
document.getElementById("modalTitle").textContent = "Edit User";
document.getElementById("userId").value = editingUser.id;
document.getElementById("username").value = editingUser.username;
document.getElementById("username").disabled = true;
document.getElementById("password").value = "";
document.getElementById("password").required = false;
document.getElementById("email").value = editingUser.email || "";
document.getElementById("role").value = editingUser.role;
document.getElementById("enabled").checked = editingUser.enabled;
// Set permissions
["view", "control", "transfer", "manage_users", "manage_clients"].forEach(perm => {
document.getElementById("perm-" + perm).checked = editingUser.permissions.includes(perm);
});
document.getElementById("formError").classList.remove("visible");
document.getElementById("userModal").classList.add("active");
}
function closeModal() {
document.getElementById("userModal").classList.remove("active");
editingUser = null;
}
async function saveUser() {
const userId = document.getElementById("userId").value;
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const email = document.getElementById("email").value || null;
const role = document.getElementById("role").value;
const enabled = document.getElementById("enabled").checked;
const permissions = [];
["view", "control", "transfer", "manage_users", "manage_clients"].forEach(perm => {
if (document.getElementById("perm-" + perm).checked) {
permissions.push(perm);
}
});
// Validation
if (!username || username.length < 3) {
showFormError("Username must be at least 3 characters");
return;
}
if (!userId && (!password || password.length < 8)) {
showFormError("Password must be at least 8 characters");
return;
}
showLoading(true);
try {
let response;
if (userId) {
// Update existing user
const updateData = { email, role, enabled };
if (password) updateData.password = password;
response = await fetch("/api/users/" + userId, {
method: "PUT",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify(updateData)
});
if (response.ok && permissions.length > 0) {
// Update permissions separately
await fetch("/api/users/" + userId + "/permissions", {
method: "PUT",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ permissions })
});
}
} else {
// Create new user
response = await fetch("/api/users", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ username, password, email, role, permissions })
});
}
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Operation failed");
}
closeModal();
loadUsers();
} catch (err) {
showFormError(err.message);
} finally {
showLoading(false);
}
}
async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"?\n\nThis action cannot be undone.`)) {
return;
}
showLoading(true);
try {
const response = await fetch("/api/users/" + id, {
method: "DELETE",
headers: { "Authorization": `Bearer ${token}` }
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Delete failed");
}
loadUsers();
} catch (err) {
showError(err.message);
} finally {
showLoading(false);
}
}
function showError(message) {
const el = document.getElementById("errorMessage");
el.textContent = message;
el.classList.add("visible");
}
function showFormError(message) {
const el = document.getElementById("formError");
el.textContent = message;
el.classList.add("visible");
}
function showLoading(show) {
document.getElementById("loadingOverlay").classList.toggle("active", show);
}
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>

View File

@@ -1,694 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GuruConnect Viewer</title>
<script src="https://cdn.jsdelivr.net/npm/fzstd@0.1.1/umd/index.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #1a1a2e;
color: #eee;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
}
.toolbar {
background: #16213e;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.toolbar button {
background: #0f3460;
color: #eee;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.toolbar button:hover {
background: #1a4a7a;
}
.toolbar button.danger {
background: #e74c3c;
}
.toolbar button.danger:hover {
background: #c0392b;
}
.toolbar .spacer {
flex: 1;
}
.toolbar .status {
font-size: 13px;
color: #aaa;
}
.toolbar .status.connected {
color: #4caf50;
}
.toolbar .status.connecting {
color: #ff9800;
}
.toolbar .status.error {
color: #e74c3c;
}
.canvas-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
overflow: hidden;
}
#viewer-canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.overlay.hidden {
display: none;
}
.overlay-content {
text-align: center;
color: #fff;
}
.overlay-content .spinner {
width: 48px;
height: 48px;
border: 4px solid #333;
border-top-color: #4caf50;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.stats {
font-size: 12px;
color: #888;
display: flex;
gap: 16px;
}
.stats span {
color: #aaa;
}
</style>
</head>
<body>
<div class="toolbar">
<button class="danger" onclick="disconnect()">Disconnect</button>
<button onclick="toggleFullscreen()">Fullscreen</button>
<button onclick="sendCtrlAltDel()">Ctrl+Alt+Del</button>
<div class="spacer"></div>
<div class="stats">
<div>FPS: <span id="fps">0</span></div>
<div>Resolution: <span id="resolution">-</span></div>
<div>Frames: <span id="frame-count">0</span></div>
</div>
<div class="status connecting" id="status">Connecting...</div>
</div>
<div class="canvas-container" id="canvas-container">
<canvas id="viewer-canvas"></canvas>
</div>
<div class="overlay" id="overlay">
<div class="overlay-content">
<div class="spinner"></div>
<div id="overlay-text">Connecting to remote desktop...</div>
</div>
</div>
<script>
// Get session ID from URL
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('session_id');
if (!sessionId) {
alert('No session ID provided');
window.close();
}
// Get viewer name from localStorage (same as dashboard)
const user = JSON.parse(localStorage.getItem('user') || 'null');
const viewerName = user?.name || user?.email || 'Technician';
// State
let ws = null;
let canvas = document.getElementById('viewer-canvas');
let ctx = canvas.getContext('2d');
let imageData = null;
let frameCount = 0;
let lastFpsTime = Date.now();
let fpsFrames = 0;
let remoteWidth = 0;
let remoteHeight = 0;
// ============================================================
// Protobuf Parsing Utilities
// ============================================================
function parseVarint(buffer, offset) {
let result = 0;
let shift = 0;
while (offset < buffer.length) {
const byte = buffer[offset++];
result |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) break;
shift += 7;
}
return { value: result, offset };
}
function parseSignedVarint(buffer, offset) {
const { value, offset: newOffset } = parseVarint(buffer, offset);
// ZigZag decode
return { value: (value >>> 1) ^ -(value & 1), offset: newOffset };
}
function parseField(buffer, offset) {
if (offset >= buffer.length) return null;
const { value: tag, offset: newOffset } = parseVarint(buffer, offset);
const fieldNumber = tag >>> 3;
const wireType = tag & 0x7;
return { fieldNumber, wireType, offset: newOffset };
}
function skipField(buffer, offset, wireType) {
switch (wireType) {
case 0: // Varint
while (offset < buffer.length && (buffer[offset++] & 0x80)) {}
return offset;
case 1: // 64-bit
return offset + 8;
case 2: // Length-delimited
const { value: len, offset: newOffset } = parseVarint(buffer, offset);
return newOffset + len;
case 5: // 32-bit
return offset + 4;
default:
throw new Error(`Unknown wire type: ${wireType}`);
}
}
function parseLengthDelimited(buffer, offset) {
const { value: len, offset: dataStart } = parseVarint(buffer, offset);
const data = buffer.slice(dataStart, dataStart + len);
return { data, offset: dataStart + len };
}
// ============================================================
// VideoFrame Parsing
// ============================================================
function parseVideoFrame(data) {
const buffer = new Uint8Array(data);
let offset = 0;
// Parse Message wrapper
let videoFrameData = null;
while (offset < buffer.length) {
const field = parseField(buffer, offset);
if (!field) break;
offset = field.offset;
if (field.fieldNumber === 10 && field.wireType === 2) {
// video_frame field
const { data: vfData, offset: newOffset } = parseLengthDelimited(buffer, offset);
videoFrameData = vfData;
offset = newOffset;
} else {
offset = skipField(buffer, offset, field.wireType);
}
}
if (!videoFrameData) return null;
// Parse VideoFrame
let rawFrameData = null;
offset = 0;
while (offset < videoFrameData.length) {
const field = parseField(videoFrameData, offset);
if (!field) break;
offset = field.offset;
if (field.fieldNumber === 10 && field.wireType === 2) {
// raw frame (oneof encoding = 10)
const { data: rfData, offset: newOffset } = parseLengthDelimited(videoFrameData, offset);
rawFrameData = rfData;
offset = newOffset;
} else {
offset = skipField(videoFrameData, offset, field.wireType);
}
}
if (!rawFrameData) return null;
// Parse RawFrame
let width = 0, height = 0, compressedData = null, isKeyframe = true;
offset = 0;
while (offset < rawFrameData.length) {
const field = parseField(rawFrameData, offset);
if (!field) break;
offset = field.offset;
switch (field.fieldNumber) {
case 1: // width
const w = parseVarint(rawFrameData, offset);
width = w.value;
offset = w.offset;
break;
case 2: // height
const h = parseVarint(rawFrameData, offset);
height = h.value;
offset = h.offset;
break;
case 3: // data (compressed BGRA)
const d = parseLengthDelimited(rawFrameData, offset);
compressedData = d.data;
offset = d.offset;
break;
case 6: // is_keyframe
const k = parseVarint(rawFrameData, offset);
isKeyframe = k.value !== 0;
offset = k.offset;
break;
default:
offset = skipField(rawFrameData, offset, field.wireType);
}
}
return { width, height, compressedData, isKeyframe };
}
// ============================================================
// Frame Rendering
// ============================================================
function renderFrame(frame) {
if (!frame || !frame.compressedData || frame.width === 0 || frame.height === 0) {
return;
}
try {
// Decompress using fzstd
const decompressed = fzstd.decompress(frame.compressedData);
// Resize canvas if needed
if (canvas.width !== frame.width || canvas.height !== frame.height) {
canvas.width = frame.width;
canvas.height = frame.height;
remoteWidth = frame.width;
remoteHeight = frame.height;
imageData = ctx.createImageData(frame.width, frame.height);
document.getElementById('resolution').textContent = `${frame.width}x${frame.height}`;
}
// Convert BGRA to RGBA
const pixels = imageData.data;
for (let i = 0; i < decompressed.length; i += 4) {
pixels[i] = decompressed[i + 2]; // R <- B
pixels[i + 1] = decompressed[i + 1]; // G
pixels[i + 2] = decompressed[i]; // B <- R
pixels[i + 3] = 255; // A (force opaque)
}
// Draw to canvas
ctx.putImageData(imageData, 0, 0);
// Update stats
frameCount++;
fpsFrames++;
document.getElementById('frame-count').textContent = frameCount;
const now = Date.now();
if (now - lastFpsTime >= 1000) {
document.getElementById('fps').textContent = fpsFrames;
fpsFrames = 0;
lastFpsTime = now;
}
} catch (e) {
console.error('Frame render error:', e);
}
}
// ============================================================
// Input Event Encoding
// ============================================================
function encodeVarint(value) {
const bytes = [];
while (value > 0x7f) {
bytes.push((value & 0x7f) | 0x80);
value >>>= 7;
}
bytes.push(value & 0x7f);
return bytes;
}
function encodeSignedVarint(value) {
// ZigZag encode
const zigzag = (value << 1) ^ (value >> 31);
return encodeVarint(zigzag >>> 0);
}
function encodeMouseEvent(x, y, buttons, eventType, wheelDeltaX = 0, wheelDeltaY = 0) {
// Build MouseEvent message
const mouseEvent = [];
// Field 1: x (varint)
mouseEvent.push(0x08); // field 1, wire type 0
mouseEvent.push(...encodeVarint(Math.round(x)));
// Field 2: y (varint)
mouseEvent.push(0x10); // field 2, wire type 0
mouseEvent.push(...encodeVarint(Math.round(y)));
// Field 3: buttons (embedded message)
if (buttons) {
const buttonsMsg = [];
if (buttons.left) { buttonsMsg.push(0x08, 0x01); } // field 1 = true
if (buttons.right) { buttonsMsg.push(0x10, 0x01); } // field 2 = true
if (buttons.middle) { buttonsMsg.push(0x18, 0x01); } // field 3 = true
if (buttonsMsg.length > 0) {
mouseEvent.push(0x1a); // field 3, wire type 2
mouseEvent.push(...encodeVarint(buttonsMsg.length));
mouseEvent.push(...buttonsMsg);
}
}
// Field 4: wheel_delta_x (sint32)
if (wheelDeltaX !== 0) {
mouseEvent.push(0x20); // field 4, wire type 0
mouseEvent.push(...encodeSignedVarint(wheelDeltaX));
}
// Field 5: wheel_delta_y (sint32)
if (wheelDeltaY !== 0) {
mouseEvent.push(0x28); // field 5, wire type 0
mouseEvent.push(...encodeSignedVarint(wheelDeltaY));
}
// Field 6: event_type (enum)
mouseEvent.push(0x30); // field 6, wire type 0
mouseEvent.push(eventType);
// Wrap in Message with field 20
const message = [];
message.push(0xa2, 0x01); // field 20, wire type 2 (20 << 3 | 2 = 162 = 0xa2, then 0x01)
message.push(...encodeVarint(mouseEvent.length));
message.push(...mouseEvent);
return new Uint8Array(message);
}
function encodeKeyEvent(vkCode, down) {
// Build KeyEvent message
const keyEvent = [];
// Field 1: down (bool)
keyEvent.push(0x08); // field 1, wire type 0
keyEvent.push(down ? 0x01 : 0x00);
// Field 3: vk_code (uint32)
keyEvent.push(0x18); // field 3, wire type 0
keyEvent.push(...encodeVarint(vkCode));
// Wrap in Message with field 21
const message = [];
message.push(0xaa, 0x01); // field 21, wire type 2 (21 << 3 | 2 = 170 = 0xaa, then 0x01)
message.push(...encodeVarint(keyEvent.length));
message.push(...keyEvent);
return new Uint8Array(message);
}
function encodeSpecialKey(keyType) {
// Build SpecialKeyEvent message
const specialKey = [];
specialKey.push(0x08); // field 1, wire type 0
specialKey.push(keyType); // 0 = CTRL_ALT_DEL
// Wrap in Message with field 22
const message = [];
message.push(0xb2, 0x01); // field 22, wire type 2
message.push(...encodeVarint(specialKey.length));
message.push(...specialKey);
return new Uint8Array(message);
}
// ============================================================
// Mouse/Keyboard Event Handlers
// ============================================================
const MOUSE_MOVE = 0;
const MOUSE_DOWN = 1;
const MOUSE_UP = 2;
const MOUSE_WHEEL = 3;
let lastMouseX = 0;
let lastMouseY = 0;
let mouseThrottle = 0;
function getMousePosition(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = remoteWidth / rect.width;
const scaleY = remoteHeight / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY
};
}
function getButtons(e) {
return {
left: (e.buttons & 1) !== 0,
right: (e.buttons & 2) !== 0,
middle: (e.buttons & 4) !== 0
};
}
canvas.addEventListener('mousemove', (e) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (remoteWidth === 0) return;
// Throttle to ~60 events/sec
const now = Date.now();
if (now - mouseThrottle < 16) return;
mouseThrottle = now;
const pos = getMousePosition(e);
const msg = encodeMouseEvent(pos.x, pos.y, getButtons(e), MOUSE_MOVE);
ws.send(msg);
});
canvas.addEventListener('mousedown', (e) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
e.preventDefault();
canvas.focus();
const pos = getMousePosition(e);
const buttons = { left: e.button === 0, right: e.button === 2, middle: e.button === 1 };
const msg = encodeMouseEvent(pos.x, pos.y, buttons, MOUSE_DOWN);
ws.send(msg);
});
canvas.addEventListener('mouseup', (e) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
e.preventDefault();
const pos = getMousePosition(e);
const buttons = { left: e.button === 0, right: e.button === 2, middle: e.button === 1 };
const msg = encodeMouseEvent(pos.x, pos.y, buttons, MOUSE_UP);
ws.send(msg);
});
canvas.addEventListener('wheel', (e) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
e.preventDefault();
const pos = getMousePosition(e);
const msg = encodeMouseEvent(pos.x, pos.y, null, MOUSE_WHEEL,
Math.round(-e.deltaX), Math.round(-e.deltaY));
ws.send(msg);
}, { passive: false });
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard events
canvas.setAttribute('tabindex', '0');
canvas.addEventListener('keydown', (e) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
e.preventDefault();
// Use keyCode for virtual key mapping
const vkCode = e.keyCode;
const msg = encodeKeyEvent(vkCode, true);
ws.send(msg);
});
canvas.addEventListener('keyup', (e) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
e.preventDefault();
const vkCode = e.keyCode;
const msg = encodeKeyEvent(vkCode, false);
ws.send(msg);
});
// Focus canvas on click
canvas.addEventListener('click', () => canvas.focus());
// ============================================================
// WebSocket Connection
// ============================================================
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('authToken');
if (!token) {
updateStatus('error', 'Not authenticated');
document.getElementById('overlay-text').textContent = 'Not logged in. Please log in first.';
return;
}
const wsUrl = `${protocol}//${window.location.host}/ws/viewer?session_id=${sessionId}&viewer_name=${encodeURIComponent(viewerName)}&token=${encodeURIComponent(token)}`;
console.log('Connecting to:', wsUrl);
updateStatus('connecting', 'Connecting...');
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
console.log('WebSocket connected');
updateStatus('connected', 'Connected');
document.getElementById('overlay').classList.add('hidden');
canvas.focus();
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const frame = parseVideoFrame(event.data);
if (frame) {
renderFrame(frame);
}
}
};
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
updateStatus('error', 'Disconnected');
document.getElementById('overlay').classList.remove('hidden');
document.getElementById('overlay-text').textContent = 'Connection closed. Reconnecting...';
// Reconnect after 2 seconds
setTimeout(connect, 2000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateStatus('error', 'Connection error');
};
}
function updateStatus(state, text) {
const status = document.getElementById('status');
status.className = 'status ' + state;
status.textContent = text;
}
// ============================================================
// Toolbar Actions
// ============================================================
function disconnect() {
if (ws) {
ws.close();
ws = null;
}
window.close();
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
function sendCtrlAltDel() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const msg = encodeSpecialKey(0); // CTRL_ALT_DEL = 0
ws.send(msg);
}
// ============================================================
// Initialization
// ============================================================
// Set window title
document.title = `GuruConnect - Session ${sessionId.substring(0, 8)}`;
// Connect on load
connect();
// Handle window close
window.addEventListener('beforeunload', () => {
if (ws) ws.close();
});
</script>
</body>
</html>