first commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BareCloud — Free BareOS hosting</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Launch VPN-protected BareOS booters on demand. In-browser terminal, no login required."
|
||||
/>
|
||||
</head>
|
||||
<body class="min-h-full flex flex-col bg-[#05070c] text-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Toasts } from "@/components/Toasts";
|
||||
import { MainLayout } from "./layout/MainLayout";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { LaunchPage } from "./pages/LaunchPage";
|
||||
import { BooterPage } from "./pages/BooterPage";
|
||||
import { MyBootersPage } from "./pages/MyBootersPage";
|
||||
import { CommunityPage } from "./pages/CommunityPage";
|
||||
import { StatsPage } from "./pages/StatsPage";
|
||||
import { RunLocallyPage } from "./pages/RunLocallyPage";
|
||||
import { UserManualPage } from "./pages/UserManualPage";
|
||||
import { TermsPage } from "./pages/TermsPage";
|
||||
import { PrivacyPage } from "./pages/PrivacyPage";
|
||||
|
||||
export default function App() {
|
||||
useEffect(() => {
|
||||
const log = console.info ?? console.log;
|
||||
log.call(console, "[BareCloud] React App mounted", {
|
||||
pathname: typeof window !== "undefined" ? window.location.pathname : "",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/launch" element={<LaunchPage />} />
|
||||
<Route path="/community" element={<CommunityPage />} />
|
||||
<Route path="/my-booters" element={<MyBootersPage />} />
|
||||
<Route path="/run-locally" element={<RunLocallyPage />} />
|
||||
<Route path="/user-manual" element={<UserManualPage />} />
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/stats" element={<StatsPage />} />
|
||||
</Route>
|
||||
<Route path="/booter/:id" element={<BooterPage />} />
|
||||
</Routes>
|
||||
<Toasts />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export type BooterSummary = {
|
||||
id: string;
|
||||
skipVpn: boolean;
|
||||
region: string;
|
||||
createdAt: number;
|
||||
lastAccessed: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
booter: BooterSummary;
|
||||
};
|
||||
|
||||
export function BooterCard({ booter }: Props) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">Bare OS instance</CardTitle>
|
||||
<CardDescription className="break-all font-mono text-xs text-white/55">{booter.id}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-white/65">
|
||||
<div>
|
||||
Region: <span className="text-white/85">{booter.region}</span>
|
||||
</div>
|
||||
<div>
|
||||
Last opened:{" "}
|
||||
<span className="text-white/85">{formatDistanceToNow(booter.lastAccessed, { addSuffix: true })}</span>
|
||||
</div>
|
||||
<div>
|
||||
Keep until:{" "}
|
||||
<span className="text-white/85">
|
||||
{format(booter.expiresAt, "MMM d, HH:mm")}{" "}
|
||||
<span className="text-white/55">({formatDistanceToNow(booter.expiresAt, { addSuffix: true })})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Created:{" "}
|
||||
<span className="text-white/85">{formatDistanceToNow(booter.createdAt, { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="default" className="shrink-0">
|
||||
<Link to={`/booter/${booter.id}`}>
|
||||
Open console
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { HeartPulse } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Props = {
|
||||
booterId: string;
|
||||
/** Called after a successful extend (e.g. refresh status). */
|
||||
onExtended?: () => void;
|
||||
};
|
||||
|
||||
export function KeepAliveButton({ booterId, onExtended }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function confirm() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/booters/${encodeURIComponent(booterId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "keepalive" }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json.error || "Request failed");
|
||||
toast.success(json.message || "Thanks — your instance will stay active.");
|
||||
onExtended?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Could not save that");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant="secondary" onClick={confirm} disabled={loading} className="w-full sm:w-auto">
|
||||
<HeartPulse className="h-4 w-4" />
|
||||
{loading ? "Saving…" : "Extend seven more days"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Loader2, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { barecloudDebug } from "@/lib/barecloud-debug";
|
||||
import { appWsClient, wsMode } from "@/lib/app-ws";
|
||||
|
||||
type Props = {
|
||||
booterId: string;
|
||||
};
|
||||
|
||||
function appBasePath(): string {
|
||||
return (import.meta.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/** Same-origin by default; override when the page is served from a different host than the API (or behind a tricky proxy). */
|
||||
/** Must match server `terminal-proxy.ts` export `WS_CLOSE_PEAR_RESTART` / `WS_CLOSE_PM2_RESTART` (4410). */
|
||||
const WS_CLOSE_PEAR_RESTART = 4410;
|
||||
/** Instance was deleted on the server — do not reconnect. */
|
||||
const WS_CLOSE_INSTANCE_DELETED = 4411;
|
||||
|
||||
let warnedMisconfiguredTerminalWsUrl = false;
|
||||
|
||||
/** Duplicate instance id in the query so a broken proxy can still be corrected server-side (see `resolveTerminalBooterIdFromUpgrade`). */
|
||||
function withTerminalBooterQueryParam(url: string, booterId: string): string {
|
||||
if (/[?&]booter=/.test(url)) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.searchParams.get("booter") === booterId) return url;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return url;
|
||||
}
|
||||
const joiner = url.includes("?") ? "&" : "?";
|
||||
return `${url}${joiner}booter=${encodeURIComponent(booterId)}`;
|
||||
}
|
||||
|
||||
function terminalWebSocketUrl(booterId: string): string {
|
||||
const enc = encodeURIComponent(booterId);
|
||||
const basePath = appBasePath();
|
||||
const explicit = import.meta.env.NEXT_PUBLIC_TERMINAL_WS_URL?.trim();
|
||||
let url: string;
|
||||
if (explicit) {
|
||||
if (/\{id\}/i.test(explicit)) {
|
||||
url = explicit.replace(/\{id\}/gi, enc);
|
||||
} else {
|
||||
if (!warnedMisconfiguredTerminalWsUrl) {
|
||||
warnedMisconfiguredTerminalWsUrl = true;
|
||||
console.warn(
|
||||
"[BareCloud] NEXT_PUBLIC_TERMINAL_WS_URL must include the literal substring {id} (example: wss://host/base/api/terminal/{id}). " +
|
||||
"A full URL without {id} would send every instance to the same console. Using this page’s origin instead.",
|
||||
);
|
||||
}
|
||||
const appOrigin = import.meta.env.NEXT_PUBLIC_APP_ORIGIN?.trim();
|
||||
if (appOrigin) {
|
||||
try {
|
||||
const u = new URL(appOrigin);
|
||||
const wsProto = u.protocol === "https:" ? "wss:" : "ws:";
|
||||
url = `${wsProto}//${u.host}${basePath}/api/terminal/${enc}`;
|
||||
} catch {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
url = `${proto}//${window.location.host}${basePath}/api/terminal/${enc}`;
|
||||
}
|
||||
} else {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
url = `${proto}//${window.location.host}${basePath}/api/terminal/${enc}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const appOrigin = import.meta.env.NEXT_PUBLIC_APP_ORIGIN?.trim();
|
||||
if (appOrigin) {
|
||||
try {
|
||||
const u = new URL(appOrigin);
|
||||
const wsProto = u.protocol === "https:" ? "wss:" : "ws:";
|
||||
url = `${wsProto}//${u.host}${basePath}/api/terminal/${enc}`;
|
||||
} catch {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
url = `${proto}//${window.location.host}${basePath}/api/terminal/${enc}`;
|
||||
}
|
||||
} else {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
url = `${proto}//${window.location.host}${basePath}/api/terminal/${enc}`;
|
||||
}
|
||||
}
|
||||
return withTerminalBooterQueryParam(url, booterId);
|
||||
}
|
||||
|
||||
function retriableWsClose(code: number): boolean {
|
||||
return code === 1006 || code === 1001 || code === 1012 || code === 1013;
|
||||
}
|
||||
|
||||
/** Strip CSI / OSC sequences so streamed PTY output can be matched reliably. */
|
||||
const ANSI_ESC = String.fromCharCode(0x1b);
|
||||
const ANSI_BEL = String.fromCharCode(0x07);
|
||||
const ANSI_OSC_RE = new RegExp(`${ANSI_ESC}\\][^${ANSI_BEL}]*${ANSI_BEL}`, "g");
|
||||
const ANSI_CSI_RE = new RegExp(`${ANSI_ESC}\\[[0-?]*[ -/]*[@-~]`, "g");
|
||||
const ANSI_ESC_RE = new RegExp(`${ANSI_ESC}[@-_]`, "g");
|
||||
|
||||
function stripAnsiForMatch(s: string): string {
|
||||
return s
|
||||
.replace(ANSI_OSC_RE, "")
|
||||
.replace(ANSI_CSI_RE, "")
|
||||
.replace(ANSI_ESC_RE, "");
|
||||
}
|
||||
|
||||
const OUTPUT_MATCH_BUF_MAX = 24_000;
|
||||
const DEFAULT_TERMINAL_SCROLLBACK = 1_000_000;
|
||||
const MAX_TERMINAL_SCROLLBACK = 4_294_967_295;
|
||||
const WHEEL_PX_PER_LINE = 24;
|
||||
|
||||
function terminalScrollback(): number {
|
||||
const raw = import.meta.env.NEXT_PUBLIC_TERMINAL_SCROLLBACK?.trim();
|
||||
if (!raw) return DEFAULT_TERMINAL_SCROLLBACK;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return DEFAULT_TERMINAL_SCROLLBACK;
|
||||
return Math.min(n, MAX_TERMINAL_SCROLLBACK);
|
||||
}
|
||||
|
||||
function pearExitedWithCodeOne(t: string): boolean {
|
||||
return (
|
||||
t.includes("pear exited (exitcode=1") ||
|
||||
(t.includes("[barecloud]") && t.includes("pear exited") && /exitcode\s*=\s*1\b/.test(t))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pear PTY failures where a fresh WebSocket / PTY often recovers:
|
||||
* kernel replication, or IPC/RPC teardown (e.g. Error: RPC destroyed).
|
||||
* Requires BareCloud's Pear exited line with exitCode=1.
|
||||
*/
|
||||
function shouldOfferPearFailureReboot(buf: string): boolean {
|
||||
const t = stripAnsiForMatch(buf).toLowerCase();
|
||||
if (!pearExitedWithCodeOne(t)) return false;
|
||||
return (
|
||||
t.includes("kernel not found after replication") ||
|
||||
t.includes("rpc destroyed")
|
||||
);
|
||||
}
|
||||
|
||||
/** Pear exited 0 / signal 0 with the server “reconnect later” line — tmux resumable session left nothing to attach to. */
|
||||
function shouldAutoRestartAfterPearCleanExit(buf: string): boolean {
|
||||
const t = stripAnsiForMatch(buf).toLowerCase();
|
||||
if (!t.includes("pear exited")) return false;
|
||||
if (!/exitcode\s*=\s*0\b/.test(t)) return false;
|
||||
if (!/signal\s*=\s*0\b/.test(t)) return false;
|
||||
return t.includes("reconnect the console") || t.includes("supervised instance");
|
||||
}
|
||||
|
||||
/** Shown when the console keeps dropping — usually network, browser sleep, or a proxy in front of the site. */
|
||||
function terminalConnectionHelp(): string {
|
||||
return [
|
||||
"If you are on Wi‑Fi or VPN, try a more stable connection.",
|
||||
"Corporate or school networks sometimes block long-lived sessions — try another network if you can.",
|
||||
"Refreshing the page often restores the console after an idle timeout.",
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
export function Terminal({ booterId }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const [status, setStatus] = useState<"connecting" | "open" | "error">("connecting");
|
||||
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
||||
const [kernelRebootBanner, setKernelRebootBanner] = useState<{
|
||||
seconds: number;
|
||||
detail: string;
|
||||
} | null>(null);
|
||||
const kernelReconnectRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
barecloudDebug("Terminal layout effect start", {
|
||||
booterId,
|
||||
hasContainerRef: Boolean(containerRef.current),
|
||||
pagePath: typeof window !== "undefined" ? window.location.pathname : "",
|
||||
basePath: appBasePath(),
|
||||
terminalWsEnv: import.meta.env.NEXT_PUBLIC_TERMINAL_WS_URL?.trim() || null,
|
||||
appOriginEnv: import.meta.env.NEXT_PUBLIC_APP_ORIGIN?.trim() || null,
|
||||
});
|
||||
const el = containerRef.current;
|
||||
if (!el) {
|
||||
barecloudDebug("Terminal: container ref is null — xterm will not start until layout is fixed", { booterId });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
barecloudDebug("Terminal mount / xterm init", {
|
||||
booterId,
|
||||
pagePath: typeof window !== "undefined" ? window.location.pathname : "",
|
||||
});
|
||||
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
scrollback: terminalScrollback(),
|
||||
scrollOnUserInput: true,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
fontSize: 14,
|
||||
theme: {
|
||||
background: "#070a0f",
|
||||
foreground: "#e8f0ff",
|
||||
cursor: "#67d4ff",
|
||||
selectionBackground: "#67d4ff44",
|
||||
},
|
||||
allowTransparency: true,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(el);
|
||||
term.attachCustomWheelEventHandler((ev) => {
|
||||
if (ev.deltaY === 0) return true;
|
||||
const active = term.buffer.active;
|
||||
const hasXtermScrollback = active.baseY > 0;
|
||||
if (!hasXtermScrollback) {
|
||||
// xterm otherwise converts wheel into up/down keys when there is no scrollback.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
const mode = ev.deltaMode;
|
||||
const rawLines =
|
||||
mode === WheelEvent.DOM_DELTA_LINE
|
||||
? ev.deltaY
|
||||
: mode === WheelEvent.DOM_DELTA_PAGE
|
||||
? ev.deltaY * Math.max(1, term.rows)
|
||||
: ev.deltaY / WHEEL_PX_PER_LINE;
|
||||
const whole = rawLines > 0 ? Math.floor(rawLines) : Math.ceil(rawLines);
|
||||
const lines = whole === 0 ? (rawLines > 0 ? 1 : -1) : whole;
|
||||
term.scrollLines(lines);
|
||||
// Prevent xterm from forwarding wheel as terminal input (which can trigger shell command history).
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
term.focus();
|
||||
});
|
||||
|
||||
const basePath = appBasePath();
|
||||
let resizePostTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastResizePosted = { cols: 0, rows: 0 };
|
||||
const scheduleResizeNotify = () => {
|
||||
if (resizePostTimer) clearTimeout(resizePostTimer);
|
||||
resizePostTimer = setTimeout(() => {
|
||||
resizePostTimer = null;
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
if (cols < 2 || rows < 1) return;
|
||||
if (cols === lastResizePosted.cols && rows === lastResizePosted.rows) return;
|
||||
lastResizePosted = { cols, rows };
|
||||
void fetch(`${basePath}/api/terminal/${encodeURIComponent(booterId)}/resize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
}).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}, 120);
|
||||
};
|
||||
|
||||
let lastFitSize = { w: 0, h: 0 };
|
||||
const runFit = () => {
|
||||
try {
|
||||
const w = el.clientWidth;
|
||||
const h = el.clientHeight;
|
||||
if (w < 2 || h < 2) {
|
||||
lastFitSize = { w: 0, h: 0 };
|
||||
return;
|
||||
}
|
||||
if (w === lastFitSize.w && h === lastFitSize.h) return;
|
||||
lastFitSize = { w, h };
|
||||
fit.fit();
|
||||
scheduleResizeNotify();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
runFit();
|
||||
requestAnimationFrame(runFit);
|
||||
});
|
||||
|
||||
termRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
setErrorDetail(null);
|
||||
|
||||
const activeWsRef: { current: WebSocket | null } = { current: null };
|
||||
const wasOpenRef = { current: false };
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let attempt = 0;
|
||||
const maxAttempts = 18;
|
||||
let welcomeShown = false;
|
||||
|
||||
const outputMatchBufRef = { current: "" };
|
||||
const kernelRebootOfferedRef = { current: false };
|
||||
const pearCleanExitRestartScheduledRef = { current: false };
|
||||
let kernelRebootInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let postPearRestartEstablishTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearKernelRebootTimers = () => {
|
||||
if (kernelRebootInterval) {
|
||||
clearInterval(kernelRebootInterval);
|
||||
kernelRebootInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearReconnect = () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleEstablishAfterPearRestart = () => {
|
||||
if (cancelled) return;
|
||||
if (postPearRestartEstablishTimer) clearTimeout(postPearRestartEstablishTimer);
|
||||
postPearRestartEstablishTimer = setTimeout(() => {
|
||||
postPearRestartEstablishTimer = null;
|
||||
if (cancelled) return;
|
||||
attempt = 0;
|
||||
clearReconnect();
|
||||
establishWs();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const scheduleReconnect = (afterMs: number, note: string) => {
|
||||
if (cancelled) return;
|
||||
attempt += 1;
|
||||
if (attempt > maxAttempts) {
|
||||
setErrorDetail(`${note} Tried ${maxAttempts} times.\n\n${terminalConnectionHelp()}`);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
/* After the first successful session, stay on "open" so the xterm is not covered by a blur overlay. */
|
||||
if (!welcomeShown) setStatus("connecting");
|
||||
const sec = Math.max(1, Math.ceil(afterMs / 1000));
|
||||
term.writeln(`\x1b[33m[BareCloud]\x1b[0m ${note} Retrying in ${sec}s… (attempt ${attempt}/${maxAttempts})`);
|
||||
reconnectTimer = setTimeout(() => establishWs(), afterMs);
|
||||
};
|
||||
|
||||
const performKernelReconnect = () => {
|
||||
if (cancelled) return;
|
||||
clearKernelRebootTimers();
|
||||
kernelRebootOfferedRef.current = false;
|
||||
outputMatchBufRef.current = "";
|
||||
setKernelRebootBanner(null);
|
||||
attempt = 0;
|
||||
clearReconnect();
|
||||
establishWs();
|
||||
};
|
||||
kernelReconnectRef.current = performKernelReconnect;
|
||||
|
||||
const scheduleKernelRebootOffer = () => {
|
||||
if (cancelled || kernelRebootOfferedRef.current) return;
|
||||
kernelRebootOfferedRef.current = true;
|
||||
const t = stripAnsiForMatch(outputMatchBufRef.current).toLowerCase();
|
||||
let detail = "Bare OS stopped unexpectedly";
|
||||
if (t.includes("kernel not found after replication")) {
|
||||
detail = "Still loading from the network";
|
||||
} else if (t.includes("rpc destroyed")) {
|
||||
detail = "Session interrupted";
|
||||
}
|
||||
setKernelRebootBanner({ seconds: 5, detail });
|
||||
kernelRebootInterval = setInterval(() => {
|
||||
setKernelRebootBanner((b) => {
|
||||
if (!b) return null;
|
||||
if (b.seconds <= 1) {
|
||||
clearKernelRebootTimers();
|
||||
queueMicrotask(() => performKernelReconnect());
|
||||
return null;
|
||||
}
|
||||
return { ...b, seconds: b.seconds - 1 };
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const schedulePearCleanExitRestart = () => {
|
||||
if (cancelled || pearCleanExitRestartScheduledRef.current) return;
|
||||
pearCleanExitRestartScheduledRef.current = true;
|
||||
outputMatchBufRef.current = "";
|
||||
clearReconnect();
|
||||
term.clear();
|
||||
term.writeln(
|
||||
"\x1b[36m[BareCloud]\x1b[0m Pear exited cleanly with nothing to attach to. The console will request a fresh Pear session in 3 seconds.",
|
||||
);
|
||||
term.writeln("");
|
||||
setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const res = await fetch(`${basePath}/api/booters/${encodeURIComponent(booterId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restart" }),
|
||||
});
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) {
|
||||
throw new Error(j.error ?? "Restart failed");
|
||||
}
|
||||
term.writeln("\x1b[36m[BareCloud]\x1b[0m Restart requested — reconnecting…");
|
||||
term.writeln("");
|
||||
scheduleEstablishAfterPearRestart();
|
||||
pearCleanExitRestartScheduledRef.current = false;
|
||||
} catch (e) {
|
||||
pearCleanExitRestartScheduledRef.current = false;
|
||||
term.writeln(
|
||||
`\x1b[31m[BareCloud]\x1b[0m ${e instanceof Error ? e.message : "Restart failed"}. Refresh the page or use Restart instance in the sidebar.`,
|
||||
);
|
||||
term.writeln("");
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const establishWs = () => {
|
||||
if (cancelled) return;
|
||||
clearReconnect();
|
||||
try {
|
||||
activeWsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
activeWsRef.current = null;
|
||||
wasOpenRef.current = false;
|
||||
|
||||
/* Subprotocol carries booter id so proxies that rewrite WS paths or strip ?booter= still attach the right instance. */
|
||||
const wsUrl = terminalWebSocketUrl(booterId);
|
||||
const subprotocols = [`barecloud.booter.${booterId}`];
|
||||
barecloudDebug("Terminal WebSocket opening", {
|
||||
booterId,
|
||||
wsUrl,
|
||||
subprotocols,
|
||||
href: typeof window !== "undefined" ? window.location.href : "",
|
||||
});
|
||||
const ws = new WebSocket(wsUrl, subprotocols);
|
||||
ws.binaryType = "arraybuffer";
|
||||
activeWsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (cancelled) return;
|
||||
if (activeWsRef.current !== ws) return;
|
||||
const hello = { barecloudTerminal: 1, booterId };
|
||||
try {
|
||||
ws.send(JSON.stringify(hello));
|
||||
barecloudDebug("Terminal WebSocket first frame (hello) sent", hello);
|
||||
} catch (e) {
|
||||
barecloudDebug("Terminal WebSocket hello send failed", e);
|
||||
}
|
||||
wasOpenRef.current = true;
|
||||
setStatus("open");
|
||||
setErrorDetail(null);
|
||||
if (!welcomeShown) {
|
||||
welcomeShown = true;
|
||||
term.writeln("\x1b[36m[BareCloud]\x1b[0m Connected — your Bare OS console is ready.");
|
||||
term.writeln("");
|
||||
} else {
|
||||
term.writeln("\x1b[36m[BareCloud]\x1b[0m Reconnected.");
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
runFit();
|
||||
requestAnimationFrame(runFit);
|
||||
term.focus();
|
||||
});
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (cancelled) return;
|
||||
/* onclose usually follows; avoid duplicate UI */
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
/* Ignore closes from a socket we already replaced (prevents reconnect storms). */
|
||||
if (activeWsRef.current !== ws) return;
|
||||
activeWsRef.current = null;
|
||||
if (cancelled) return;
|
||||
const wasOpen = wasOpenRef.current;
|
||||
wasOpenRef.current = false;
|
||||
|
||||
barecloudDebug("Terminal WebSocket closed", {
|
||||
booterId,
|
||||
code: ev.code,
|
||||
reason: ev.reason ? String(ev.reason) : "",
|
||||
wasClean: ev.wasClean,
|
||||
hadBeenOpen: wasOpen,
|
||||
});
|
||||
|
||||
if (ev.code === 4404) {
|
||||
setErrorDetail(
|
||||
"This instance could not be started on the server. Try again in a moment; if it keeps happening, the host may need attention.",
|
||||
);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (ev.code === 4403) {
|
||||
setErrorDetail(
|
||||
"There is no data for this instance on the server anymore, or the server could not verify the console session.\n\nStart a new instance from the home page if needed. After upgrading BareCloud, hard-refresh this page (Ctrl+Shift+R) so the console sends the required first-message handshake.",
|
||||
);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (ev.code === 4409) {
|
||||
setErrorDetail(
|
||||
"Another tab already has the console open for this instance. Close it there first, then refresh this page.",
|
||||
);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (ev.code === WS_CLOSE_PEAR_RESTART) {
|
||||
scheduleEstablishAfterPearRestart();
|
||||
return;
|
||||
}
|
||||
if (ev.code === WS_CLOSE_INSTANCE_DELETED) {
|
||||
setErrorDetail(
|
||||
"This instance was removed on the server (deleted or purged). Open the home page if you need a new instance.",
|
||||
);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (ev.code === 1011 && ev.reason) {
|
||||
setErrorDetail(`Server closed the session (${String(ev.reason)}).`);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (!wasOpen && ev.code !== 1000) {
|
||||
if (retriableWsClose(ev.code)) {
|
||||
const wait = Math.min(8000, 400 + attempt * 350);
|
||||
scheduleReconnect(wait, "Could not open the console connection.");
|
||||
return;
|
||||
}
|
||||
setErrorDetail(
|
||||
"Could not reach the console. Check that you are online and this site is reachable, then refresh the page.",
|
||||
);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (wasOpen && ev.code === 1000) {
|
||||
return;
|
||||
}
|
||||
if (wasOpen && ev.code !== 1000) {
|
||||
if (retriableWsClose(ev.code)) {
|
||||
const wait = Math.min(10000, 500 + attempt * 400);
|
||||
scheduleReconnect(wait, "The connection dropped.");
|
||||
return;
|
||||
}
|
||||
const reason = ev.reason ? String(ev.reason).trim() : "";
|
||||
const second = reason ? `Details: ${reason}` : "The network closed the session unexpectedly.";
|
||||
const lines = [
|
||||
"The console disconnected. Wait a moment and refresh, or try another network.",
|
||||
`${second}\n\n${terminalConnectionHelp()}`,
|
||||
];
|
||||
setErrorDetail(lines.join("\n\n"));
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
const d = ev.data;
|
||||
const chunk = typeof d === "string" ? d : new TextDecoder().decode(d as ArrayBuffer);
|
||||
if (typeof d === "string") term.write(d);
|
||||
else term.write(new Uint8Array(d as ArrayBuffer));
|
||||
|
||||
const prev = outputMatchBufRef.current;
|
||||
outputMatchBufRef.current = (prev + chunk).slice(-OUTPUT_MATCH_BUF_MAX);
|
||||
if (
|
||||
!kernelRebootOfferedRef.current &&
|
||||
shouldOfferPearFailureReboot(outputMatchBufRef.current)
|
||||
) {
|
||||
scheduleKernelRebootOffer();
|
||||
}
|
||||
if (
|
||||
!kernelRebootOfferedRef.current &&
|
||||
!pearCleanExitRestartScheduledRef.current &&
|
||||
shouldAutoRestartAfterPearCleanExit(outputMatchBufRef.current)
|
||||
) {
|
||||
schedulePearCleanExitRestart();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let offTerminalMux: (() => void) | null = null;
|
||||
if (wsMode() === "multiplex") {
|
||||
/* Rollout guardrail: terminal data path still uses legacy dedicated WS until server-side channel parity lands. */
|
||||
offTerminalMux = appWsClient().subscribe(
|
||||
"error",
|
||||
() => {
|
||||
/* ignore app-level terminal-channel-not-ready errors for now */
|
||||
},
|
||||
{ booterId },
|
||||
);
|
||||
barecloudDebug("Terminal: multiplex mode enabled, using legacy terminal stream fallback", { booterId });
|
||||
}
|
||||
|
||||
establishWs();
|
||||
|
||||
const d = term.onData((payload) => {
|
||||
const s = activeWsRef.current;
|
||||
if (s && s.readyState === WebSocket.OPEN) s.send(payload);
|
||||
});
|
||||
|
||||
let roRaf = 0;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (roRaf) cancelAnimationFrame(roRaf);
|
||||
roRaf = requestAnimationFrame(() => {
|
||||
roRaf = 0;
|
||||
runFit();
|
||||
});
|
||||
});
|
||||
ro.observe(el);
|
||||
|
||||
const onWin = () => {
|
||||
if (roRaf) cancelAnimationFrame(roRaf);
|
||||
roRaf = requestAnimationFrame(() => {
|
||||
roRaf = 0;
|
||||
runFit();
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", onWin);
|
||||
|
||||
return () => {
|
||||
barecloudDebug("Terminal unmount / effect cleanup", { booterId });
|
||||
cancelled = true;
|
||||
wasOpenRef.current = false;
|
||||
clearKernelRebootTimers();
|
||||
kernelRebootOfferedRef.current = false;
|
||||
pearCleanExitRestartScheduledRef.current = false;
|
||||
if (postPearRestartEstablishTimer) {
|
||||
clearTimeout(postPearRestartEstablishTimer);
|
||||
postPearRestartEstablishTimer = null;
|
||||
}
|
||||
outputMatchBufRef.current = "";
|
||||
setKernelRebootBanner(null);
|
||||
clearReconnect();
|
||||
if (resizePostTimer) clearTimeout(resizePostTimer);
|
||||
if (roRaf) cancelAnimationFrame(roRaf);
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", onWin);
|
||||
d.dispose();
|
||||
offTerminalMux?.();
|
||||
try {
|
||||
activeWsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
activeWsRef.current = null;
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [booterId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="barecloud-xterm-host relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-black/40 shadow-inner shadow-black/60"
|
||||
aria-label="Bare OS console"
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 0) termRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{kernelRebootBanner != null && (
|
||||
<div className="absolute left-0 right-0 top-0 z-20 flex flex-wrap items-center justify-between gap-3 border-b border-amber-400/30 bg-amber-950/90 px-3 py-2.5 text-sm text-amber-50 shadow-md backdrop-blur">
|
||||
<span className="min-w-0 flex-1 leading-snug">
|
||||
<span className="font-medium">{kernelRebootBanner.detail}</span>
|
||||
{" — "}
|
||||
reconnecting in <strong className="tabular-nums">{kernelRebootBanner.seconds}</strong>s, or tap{" "}
|
||||
<strong>Reconnect now</strong>.
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shrink-0 border-amber-400/40 bg-amber-900/50 text-amber-50 hover:bg-amber-800/60"
|
||||
onClick={() => kernelReconnectRef.current?.()}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Reconnect now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{status === "connecting" && (
|
||||
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-center gap-2 border-b border-white/10 bg-zinc-950/95 py-2.5 text-sm text-white/90 shadow-md">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
|
||||
Connecting to console…
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="absolute bottom-3 left-3 right-3 z-10 whitespace-pre-line rounded-xl border border-red-400/25 bg-red-950/70 px-3 py-2 text-xs leading-relaxed text-red-100 backdrop-blur">
|
||||
{errorDetail ?? "Console connection lost. Refresh the page and try again."}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative z-0 flex min-h-0 flex-1 flex-col overflow-x-hidden px-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export function Toasts() {
|
||||
return (
|
||||
<Toaster
|
||||
richColors
|
||||
theme="dark"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"border border-white/10 bg-black/70 text-white backdrop-blur-xl shadow-2xl shadow-black/40",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Pear logo (from Pears / Holepunch brand assets). */
|
||||
export function PearMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 127 182"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M58.9658 0H68.0374V17.9736H58.9658V0Z" fill="#B0D944" />
|
||||
<path
|
||||
d="M54.4299 23.6604V27.3005H45.3583V31.8506H81.6449V27.3005H72.5732V20.2471H63.5016V23.6604H54.4299Z"
|
||||
fill="#B0D944"
|
||||
/>
|
||||
<path d="M90.7166 34.124H63.5016V37.5374H36.2866V45.7275H90.7166V34.124Z" fill="#B0D944" />
|
||||
<path d="M99.7882 48.0041H63.5016V51.4175H27.215V59.6076H99.7882V48.0041Z" fill="#B0D944" />
|
||||
<path d="M99.7882 61.8811H63.5016V65.2944H27.215V73.4846H99.7882V61.8811Z" fill="#B0D944" />
|
||||
<path d="M108.86 75.758H63.5016V79.1714H18.1433V87.3615H108.86V75.758Z" fill="#B0D944" />
|
||||
<path d="M108.86 89.635H63.5016V93.0483H18.1433V101.238H108.86V89.635Z" fill="#B0D944" />
|
||||
<path d="M63.4984 103.512V106.925H9.07166V115.115H117.932V103.512H63.4984Z" fill="#B0D944" />
|
||||
<path d="M127 117.392H63.4984V120.805H0V128.996H127V117.392Z" fill="#B0D944" />
|
||||
<path d="M127 131.269H63.4984V134.682H0V142.873H127V131.269Z" fill="#B0D944" />
|
||||
<path d="M127 145.146H63.4984V148.559H0V156.749H127V145.146Z" fill="#B0D944" />
|
||||
<path d="M108.86 159.023H63.5016V162.436H18.1433V170.626H108.86V159.023Z" fill="#B0D944" />
|
||||
<path d="M90.7166 172.9H63.5016V176.313H36.2866V182H90.7166V172.9Z" fill="#B0D944" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium tracking-wide transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-white/15 bg-white/5 text-white/85",
|
||||
success: "border-emerald-400/25 bg-emerald-500/15 text-emerald-200",
|
||||
warning: "border-amber-400/25 bg-amber-500/15 text-amber-100",
|
||||
destructive: "border-red-400/25 bg-red-500/15 text-red-100",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/60 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-gradient-to-r from-sky-500 to-indigo-500 text-white shadow-lg shadow-sky-500/20 hover:from-sky-400 hover:to-indigo-400",
|
||||
secondary:
|
||||
"border border-white/10 bg-white/5 text-white hover:bg-white/10 backdrop-blur",
|
||||
ghost: "text-white/80 hover:bg-white/10 hover:text-white",
|
||||
destructive: "bg-red-600 text-white hover:bg-red-500",
|
||||
outline:
|
||||
"border border-white/15 bg-transparent text-white hover:bg-white/10",
|
||||
warning:
|
||||
"border border-amber-500/45 bg-amber-950/70 text-amber-50 shadow-lg shadow-amber-900/25 hover:bg-amber-900/80 hover:border-amber-400/50",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-5 py-2",
|
||||
sm: "h-9 rounded-lg px-3",
|
||||
lg: "h-12 rounded-xl px-8 text-base",
|
||||
icon: "h-10 w-10 rounded-xl",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref as never} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-2xl border border-white/10 bg-white/[0.04] shadow-xl shadow-black/40 backdrop-blur-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-2 p-6 pb-3", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-lg font-semibold tracking-tight text-white", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-white/60", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ConfirmDialogTone = "default" | "destructive" | "warning";
|
||||
|
||||
export type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
tone?: ConfirmDialogTone;
|
||||
pending?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
tone = "default",
|
||||
pending = false,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const [internalPending, setInternalPending] = React.useState(false);
|
||||
const busy = pending || internalPending;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setInternalPending(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setInternalPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmVariant =
|
||||
tone === "destructive" ? "destructive" : tone === "warning" ? "warning" : "default";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-[420px] sm:max-w-md"
|
||||
disableCloseButton={busy}
|
||||
onPointerDownOutside={(e) => busy && e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => busy && e.preventDefault()}
|
||||
>
|
||||
<div className="flex gap-4 px-6 pb-1 pr-14 pt-6">
|
||||
{icon ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.06] text-white/90",
|
||||
tone === "destructive" && "border-red-500/30 bg-red-950/50 text-red-200",
|
||||
tone === "warning" && "border-amber-500/35 bg-amber-950/45 text-amber-200",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<DialogHeader className={cn("flex-1 gap-2 p-0", !icon && "pr-0")}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? (
|
||||
<DialogDescription className="text-white/65">{description}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter className="mt-2">
|
||||
<Button type="button" variant="secondary" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button type="button" variant={confirmVariant} disabled={busy} onClick={() => void handleConfirm()}>
|
||||
{busy ? "Please wait…" : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bc-dialog-overlay fixed inset-0 z-[100] bg-[#020306]/85 backdrop-blur-md",
|
||||
"data-[state=open]:animate-[bc-dialog-overlay-in_200ms_ease-out_both] data-[state=closed]:animate-[bc-dialog-overlay-out_150ms_ease-in_both]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
/** When true, the corner close control is non-interactive (e.g. while an action is in flight). */
|
||||
disableCloseButton?: boolean;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, DialogContentProps>(
|
||||
({ className, children, disableCloseButton, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
{/*
|
||||
Flex host keeps the panel centered in the *viewport* (not a transformed ancestor).
|
||||
pointer-events: wrapper none, panel auto — backdrop clicks still reach the overlay.
|
||||
*/}
|
||||
<div className="fixed inset-0 z-[101] flex items-center justify-center overflow-y-auto overscroll-y-contain p-4 sm:p-6 pointer-events-none">
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bc-dialog-content pointer-events-auto relative z-[102] flex w-[min(100vw-2rem,28rem)] max-w-md flex-col overflow-hidden rounded-2xl border border-white/[0.14] bg-[#070a11]/[0.97] p-0 shadow-[0_24px_80px_-12px_rgba(0,0,0,0.75)] shadow-black/60 backdrop-blur-2xl outline-none",
|
||||
"data-[state=open]:animate-[bc-dialog-content-in_220ms_cubic-bezier(0.16,1,0.3,1)_both] data-[state=closed]:animate-[bc-dialog-content-out_160ms_ease-in_both]",
|
||||
"max-h-[min(90dvh,640px)] my-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none h-px w-full shrink-0 bg-gradient-to-r from-transparent via-sky-500/45 to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
type="button"
|
||||
disabled={disableCloseButton}
|
||||
aria-disabled={disableCloseButton || undefined}
|
||||
className={cn(
|
||||
"absolute right-3.5 top-3.5 inline-flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 bg-white/[0.06] text-white/70 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/50",
|
||||
disableCloseButton && "pointer-events-none opacity-40",
|
||||
)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
),
|
||||
);
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col gap-1.5 px-6 pb-2 pr-14 pt-6 text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 border-t border-white/[0.08] bg-black/25 px-6 py-4 sm:flex-row sm:justify-end sm:gap-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold tracking-tight text-white", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm leading-relaxed text-white/60", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-2 text-sm text-white shadow-inner shadow-black/30 outline-none backdrop-blur transition placeholder:text-white/35 focus-visible:border-sky-400/60 focus-visible:ring-2 focus-visible:ring-sky-400/25 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn("text-sm font-medium text-white/80", className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-white/10",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,143 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #05070c;
|
||||
--foreground: #e9f0ff;
|
||||
--muted: rgba(255, 255, 255, 0.65);
|
||||
--card: rgba(255, 255, 255, 0.04);
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--ring: rgba(103, 212, 255, 0.35);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1200px 600px at 20% 0%, rgba(56, 189, 248, 0.18), transparent 55%),
|
||||
radial-gradient(900px 500px at 85% 10%, rgba(99, 102, 241, 0.22), transparent 55%),
|
||||
radial-gradient(700px 450px at 60% 90%, rgba(16, 185, 129, 0.12), transparent 55%),
|
||||
linear-gradient(to bottom, #05070c, #05070c);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Thin, theme-matched scrollbars (Firefox + Chromium/WebKit). */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(103, 212, 255, 0.45) rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(103, 212, 255, 0.6), rgba(129, 140, 248, 0.52));
|
||||
border: 2px solid rgba(5, 7, 12, 0.85);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(125, 223, 255, 0.78), rgba(147, 159, 255, 0.72));
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(103, 212, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Flex + min-height:0 avoids the last row being clipped when a parent uses overflow:hidden (common xterm.js + FitAddon issue). */
|
||||
.barecloud-xterm-host .xterm {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
.barecloud-xterm-host .xterm-viewport {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(103, 212, 255, 0.68) rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.barecloud-xterm-host .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.barecloud-xterm-host .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.025));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.barecloud-xterm-host .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(103, 212, 255, 0.82), rgba(129, 140, 248, 0.7)),
|
||||
rgba(103, 212, 255, 0.5);
|
||||
border: 2px solid rgba(7, 10, 15, 0.92);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 10px rgba(103, 212, 255, 0.18);
|
||||
}
|
||||
|
||||
.barecloud-xterm-host .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(125, 223, 255, 0.95), rgba(147, 159, 255, 0.86)),
|
||||
rgba(103, 212, 255, 0.65);
|
||||
}
|
||||
|
||||
@keyframes bc-dialog-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes bc-dialog-overlay-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes bc-dialog-content-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes bc-dialog-content-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bc-dialog-overlay,
|
||||
.bc-dialog-content {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Outlet, Link, useLocation } from "react-router-dom";
|
||||
import { CloudCog } from "lucide-react";
|
||||
import { PearMark } from "@/components/icons/PearMark";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function MainLayout() {
|
||||
const { pathname } = useLocation();
|
||||
const isHome = pathname === "/";
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh min-h-0 flex-col overflow-hidden">
|
||||
<header className="sticky top-0 z-40 shrink-0 border-b border-white/10 bg-black/30 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:gap-4 sm:px-5 sm:py-4">
|
||||
<Link to="/" className="flex items-center gap-2 font-semibold tracking-tight">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 bg-white/5 shadow-inner shadow-black/40">
|
||||
<CloudCog className="h-5 w-5 text-sky-300" />
|
||||
</span>
|
||||
<span>BareCloud</span>
|
||||
</Link>
|
||||
<nav className="flex flex-wrap items-center justify-end gap-x-3 gap-y-2 text-xs text-white/75 sm:gap-x-6 sm:text-sm">
|
||||
<Link className="transition-colors hover:text-white" to="/launch">
|
||||
Start instance
|
||||
</Link>
|
||||
<Link className="transition-colors hover:text-white" to="/community">
|
||||
Community
|
||||
</Link>
|
||||
<Link className="transition-colors hover:text-white" to="/my-booters">
|
||||
My instances
|
||||
</Link>
|
||||
<Link className="transition-colors hover:text-white" to="/stats">
|
||||
Host stats
|
||||
</Link>
|
||||
<a
|
||||
href="https://pears.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-2.5 py-1.5 text-xs font-medium text-white/80 shadow-sm shadow-black/20 transition-colors hover:border-[#B0D944]/35 hover:bg-[#B0D944]/[0.08] hover:text-white sm:text-sm"
|
||||
>
|
||||
<PearMark className="h-[18px] w-[18px] shrink-0" />
|
||||
<span className="whitespace-nowrap">Built with Pears</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main
|
||||
className={cn(
|
||||
"mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col px-5",
|
||||
isHome ? "overflow-y-auto py-4 sm:py-8" : "overflow-y-auto py-5 sm:py-10",
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer
|
||||
className={cn(
|
||||
"shrink-0 border-t border-white/10 text-center text-[11px] text-white/45",
|
||||
isHome ? "py-2.5 sm:py-3" : "py-7",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center gap-1 px-4">
|
||||
<p className="leading-snug">
|
||||
BareCloud hosts Bare OS for the community. Instances are removed if not extended within seven days — open yours
|
||||
and tap extend before the deadline to keep them.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 text-white/60 leading-none">
|
||||
<Link className="transition-colors hover:text-white" to="/terms">
|
||||
Terms of Service
|
||||
</Link>
|
||||
<span aria-hidden>•</span>
|
||||
<Link className="transition-colors hover:text-white" to="/privacy">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
type WsListener<T = unknown> = (payload: T, frame: Record<string, unknown>) => void;
|
||||
|
||||
type SubKey = string;
|
||||
|
||||
type Subscription = {
|
||||
key: SubKey;
|
||||
topic: string;
|
||||
booterId?: string;
|
||||
ids?: string[];
|
||||
listeners: Set<WsListener>;
|
||||
};
|
||||
|
||||
function wsBasePath(): string {
|
||||
return (import.meta.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function wsMode(): "legacy" | "multiplex" {
|
||||
const raw = (import.meta.env.NEXT_PUBLIC_WS_MODE ?? "").trim().toLowerCase();
|
||||
return raw === "multiplex" ? "multiplex" : "legacy";
|
||||
}
|
||||
|
||||
function wsUrl(): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${proto}//${window.location.host}${wsBasePath()}/api/ws`;
|
||||
}
|
||||
|
||||
function subKey(topic: string, booterId?: string, ids?: string[]): string {
|
||||
const idPart = booterId ? `:${booterId}` : "";
|
||||
const idsPart = ids && ids.length > 0 ? `:${[...ids].sort().join(",")}` : "";
|
||||
return `${topic}${idPart}${idsPart}`;
|
||||
}
|
||||
|
||||
class AppWsClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private attempt = 0;
|
||||
private readonly subs = new Map<SubKey, Subscription>();
|
||||
|
||||
private ensureConnected(): void {
|
||||
if (wsMode() !== "multiplex") return;
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
|
||||
this.ws = new WebSocket(wsUrl());
|
||||
this.ws.onopen = () => {
|
||||
this.attempt = 0;
|
||||
this.ws?.send(JSON.stringify({ type: "hello", client: "barecloud-web" }));
|
||||
for (const s of this.subs.values()) {
|
||||
this.ws?.send(JSON.stringify({ type: "subscribe", topic: s.topic, booterId: s.booterId, ids: s.ids }));
|
||||
}
|
||||
};
|
||||
this.ws.onmessage = (ev) => {
|
||||
try {
|
||||
const frame = JSON.parse(String(ev.data)) as Record<string, unknown>;
|
||||
const topic = typeof frame.type === "string" ? frame.type : "";
|
||||
if (!topic) return;
|
||||
for (const s of this.subs.values()) {
|
||||
const byTopic = s.topic === topic;
|
||||
const byBooter =
|
||||
!s.booterId || typeof frame.booterId !== "string" || frame.booterId === s.booterId;
|
||||
if (!byTopic || !byBooter) continue;
|
||||
for (const listener of s.listeners) listener(frame.payload, frame);
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed frames */
|
||||
}
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
const wait = Math.min(8000, 400 + this.attempt * 350);
|
||||
this.attempt += 1;
|
||||
this.reconnectTimer = setTimeout(() => this.ensureConnected(), wait);
|
||||
};
|
||||
this.ws.onerror = () => {
|
||||
/* onclose handles reconnect */
|
||||
};
|
||||
}
|
||||
|
||||
subscribe<T = unknown>(
|
||||
topic: string,
|
||||
listener: WsListener<T>,
|
||||
opts?: { booterId?: string; ids?: string[] },
|
||||
): () => void {
|
||||
if (wsMode() !== "multiplex") {
|
||||
return () => {
|
||||
/* no-op in legacy mode */
|
||||
};
|
||||
}
|
||||
const key = subKey(topic, opts?.booterId, opts?.ids);
|
||||
let sub = this.subs.get(key);
|
||||
if (!sub) {
|
||||
sub = { key, topic, booterId: opts?.booterId, ids: opts?.ids, listeners: new Set() };
|
||||
this.subs.set(key, sub);
|
||||
this.ensureConnected();
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: "subscribe", topic, booterId: opts?.booterId, ids: opts?.ids }));
|
||||
}
|
||||
}
|
||||
sub.listeners.add(listener as WsListener);
|
||||
return () => {
|
||||
const current = this.subs.get(key);
|
||||
if (!current) return;
|
||||
current.listeners.delete(listener as WsListener);
|
||||
if (current.listeners.size > 0) return;
|
||||
this.subs.delete(key);
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: "unsubscribe", topic, booterId: opts?.booterId }));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const singleton = new AppWsClient();
|
||||
|
||||
export function appWsClient(): AppWsClient {
|
||||
return singleton;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Browser console diagnostics (prefix `[BareCloud:debug]`).
|
||||
* Uses `console.info` so lines appear under the **Info** level in Chrome (not hidden when “Verbose” is off).
|
||||
*/
|
||||
export function barecloudDebug(...args: unknown[]): void {
|
||||
try {
|
||||
if (typeof console !== "undefined" && typeof console.info === "function") {
|
||||
console.info("[BareCloud:debug]", ...args);
|
||||
} else if (typeof console !== "undefined" && typeof console.log === "function") {
|
||||
console.log("[BareCloud:debug]", ...args);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Browser-only helpers for remembering instance IDs without accounts. */
|
||||
|
||||
const KEY = "barecloud-booters";
|
||||
|
||||
export function getStoredBooterIds(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((x): x is string => typeof x === "string");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberBooterId(id: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const prev = getStoredBooterIds().filter((x) => x !== id);
|
||||
prev.unshift(id);
|
||||
window.localStorage.setItem(KEY, JSON.stringify(prev.slice(0, 64)));
|
||||
}
|
||||
|
||||
export function forgetBooterId(id: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const next = getStoredBooterIds().filter((x) => x !== id);
|
||||
window.localStorage.setItem(KEY, JSON.stringify(next));
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { responseJsonObject } from "@/lib/json-response";
|
||||
|
||||
export type HostStatsPayload = {
|
||||
generatedAt: number;
|
||||
security: { redacted: boolean };
|
||||
host: {
|
||||
platform: string;
|
||||
release: string;
|
||||
hostnameAlias: string;
|
||||
distro: string | null;
|
||||
uptimeSec: number;
|
||||
cpu: {
|
||||
usagePct: number;
|
||||
cores: number;
|
||||
loadAvg: { one: number; five: number; fifteen: number };
|
||||
perCore: Array<{ core: string; usagePct: number }>;
|
||||
};
|
||||
memory: {
|
||||
totalBytes: number;
|
||||
availableBytes: number;
|
||||
usedBytes: number;
|
||||
usedPct: number;
|
||||
swapTotalBytes: number;
|
||||
swapUsedBytes: number;
|
||||
swapUsedPct: number;
|
||||
};
|
||||
pressure: {
|
||||
cpu: { avg10: number; avg60: number; avg300: number; total: number } | null;
|
||||
memory: { avg10: number; avg60: number; avg300: number; total: number } | null;
|
||||
io: { avg10: number; avg60: number; avg300: number; total: number } | null;
|
||||
};
|
||||
filesystems: Array<{
|
||||
mount: string;
|
||||
fsType: string;
|
||||
sizeBytes: number;
|
||||
usedBytes: number;
|
||||
availBytes: number;
|
||||
usedPct: number;
|
||||
inodesTotal: number;
|
||||
inodesUsed: number;
|
||||
inodeUsedPct: number;
|
||||
}>;
|
||||
network: Array<{
|
||||
alias: string;
|
||||
rxBytes: number;
|
||||
txBytes: number;
|
||||
rxRateBps: number;
|
||||
txRateBps: number;
|
||||
rxDrops: number;
|
||||
txDrops: number;
|
||||
rxErrors: number;
|
||||
txErrors: number;
|
||||
}>;
|
||||
disks: Array<{
|
||||
alias: string;
|
||||
readBytes: number;
|
||||
writeBytes: number;
|
||||
readBps: number;
|
||||
writeBps: number;
|
||||
ioInProgress: number;
|
||||
ioMs: number;
|
||||
readsCompleted: number;
|
||||
writesCompleted: number;
|
||||
}>;
|
||||
thermal: Array<{ alias: string; celsius: number }>;
|
||||
topProcesses: Array<{
|
||||
alias: string;
|
||||
cpuPct: number;
|
||||
memPct: number;
|
||||
rssBytes: number;
|
||||
state: string;
|
||||
command: string;
|
||||
}>;
|
||||
};
|
||||
instances: {
|
||||
fleet: {
|
||||
totalBooters: number;
|
||||
runningBooters: number;
|
||||
terminalActive: number;
|
||||
detachedSessions: number;
|
||||
tmuxSessions: number;
|
||||
};
|
||||
booters: Array<{
|
||||
alias: string;
|
||||
createdAt: number;
|
||||
lastAccessed: number;
|
||||
expiresAt: number;
|
||||
running: boolean;
|
||||
terminalSessionActive: boolean;
|
||||
detachedTmuxSession: boolean;
|
||||
health: string;
|
||||
tmux: {
|
||||
alias: string;
|
||||
exists: boolean;
|
||||
attachedClients: number;
|
||||
windows: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
tmux: {
|
||||
enabled: boolean;
|
||||
sessionsDetected: number;
|
||||
};
|
||||
};
|
||||
|
||||
function num(v: unknown): number {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function bool(v: unknown): boolean {
|
||||
return Boolean(v);
|
||||
}
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
function obj(v: unknown): Record<string, unknown> {
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>;
|
||||
return {};
|
||||
}
|
||||
|
||||
function arr(v: unknown): unknown[] {
|
||||
return Array.isArray(v) ? v : [];
|
||||
}
|
||||
|
||||
export function parseHostStatsPayload(raw: Record<string, unknown>): HostStatsPayload {
|
||||
const host = obj(raw.host);
|
||||
const cpu = obj(host.cpu);
|
||||
const memory = obj(host.memory);
|
||||
const pressure = obj(host.pressure);
|
||||
const instances = obj(raw.instances);
|
||||
const fleet = obj(instances.fleet);
|
||||
const tmuxRuntime = obj(raw.tmux);
|
||||
|
||||
return {
|
||||
generatedAt: num(raw.generatedAt),
|
||||
security: { redacted: bool(obj(raw.security).redacted) },
|
||||
host: {
|
||||
platform: str(host.platform),
|
||||
release: str(host.release),
|
||||
hostnameAlias: str(host.hostnameAlias),
|
||||
distro: typeof host.distro === "string" ? host.distro : null,
|
||||
uptimeSec: num(host.uptimeSec),
|
||||
cpu: {
|
||||
usagePct: num(cpu.usagePct),
|
||||
cores: num(cpu.cores),
|
||||
loadAvg: {
|
||||
one: num(obj(cpu.loadAvg).one),
|
||||
five: num(obj(cpu.loadAvg).five),
|
||||
fifteen: num(obj(cpu.loadAvg).fifteen),
|
||||
},
|
||||
perCore: arr(cpu.perCore).map((x) => {
|
||||
const o = obj(x);
|
||||
return { core: str(o.core), usagePct: num(o.usagePct) };
|
||||
}),
|
||||
},
|
||||
memory: {
|
||||
totalBytes: num(memory.totalBytes),
|
||||
availableBytes: num(memory.availableBytes),
|
||||
usedBytes: num(memory.usedBytes),
|
||||
usedPct: num(memory.usedPct),
|
||||
swapTotalBytes: num(memory.swapTotalBytes),
|
||||
swapUsedBytes: num(memory.swapUsedBytes),
|
||||
swapUsedPct: num(memory.swapUsedPct),
|
||||
},
|
||||
pressure: {
|
||||
cpu: pressure.cpu ? (obj(pressure.cpu) as { avg10: number; avg60: number; avg300: number; total: number }) : null,
|
||||
memory: pressure.memory
|
||||
? (obj(pressure.memory) as { avg10: number; avg60: number; avg300: number; total: number })
|
||||
: null,
|
||||
io: pressure.io ? (obj(pressure.io) as { avg10: number; avg60: number; avg300: number; total: number }) : null,
|
||||
},
|
||||
filesystems: arr(host.filesystems).map((x) => {
|
||||
const o = obj(x);
|
||||
return {
|
||||
mount: str(o.mount),
|
||||
fsType: str(o.fsType),
|
||||
sizeBytes: num(o.sizeBytes),
|
||||
usedBytes: num(o.usedBytes),
|
||||
availBytes: num(o.availBytes),
|
||||
usedPct: num(o.usedPct),
|
||||
inodesTotal: num(o.inodesTotal),
|
||||
inodesUsed: num(o.inodesUsed),
|
||||
inodeUsedPct: num(o.inodeUsedPct),
|
||||
};
|
||||
}),
|
||||
network: arr(host.network).map((x) => {
|
||||
const o = obj(x);
|
||||
return {
|
||||
alias: str(o.alias),
|
||||
rxBytes: num(o.rxBytes),
|
||||
txBytes: num(o.txBytes),
|
||||
rxRateBps: num(o.rxRateBps),
|
||||
txRateBps: num(o.txRateBps),
|
||||
rxDrops: num(o.rxDrops),
|
||||
txDrops: num(o.txDrops),
|
||||
rxErrors: num(o.rxErrors),
|
||||
txErrors: num(o.txErrors),
|
||||
};
|
||||
}),
|
||||
disks: arr(host.disks).map((x) => {
|
||||
const o = obj(x);
|
||||
return {
|
||||
alias: str(o.alias),
|
||||
readBytes: num(o.readBytes),
|
||||
writeBytes: num(o.writeBytes),
|
||||
readBps: num(o.readBps),
|
||||
writeBps: num(o.writeBps),
|
||||
ioInProgress: num(o.ioInProgress),
|
||||
ioMs: num(o.ioMs),
|
||||
readsCompleted: num(o.readsCompleted),
|
||||
writesCompleted: num(o.writesCompleted),
|
||||
};
|
||||
}),
|
||||
thermal: arr(host.thermal).map((x) => {
|
||||
const o = obj(x);
|
||||
return { alias: str(o.alias), celsius: num(o.celsius) };
|
||||
}),
|
||||
topProcesses: arr(host.topProcesses).map((x) => {
|
||||
const o = obj(x);
|
||||
return {
|
||||
alias: str(o.alias),
|
||||
cpuPct: num(o.cpuPct),
|
||||
memPct: num(o.memPct),
|
||||
rssBytes: num(o.rssBytes),
|
||||
state: str(o.state),
|
||||
command: str(o.command),
|
||||
};
|
||||
}),
|
||||
},
|
||||
instances: {
|
||||
fleet: {
|
||||
totalBooters: num(fleet.totalBooters),
|
||||
runningBooters: num(fleet.runningBooters),
|
||||
terminalActive: num(fleet.terminalActive),
|
||||
detachedSessions: num(fleet.detachedSessions),
|
||||
tmuxSessions: num(fleet.tmuxSessions),
|
||||
},
|
||||
booters: arr(instances.booters).map((x) => {
|
||||
const o = obj(x);
|
||||
const tmux = obj(o.tmux);
|
||||
return {
|
||||
alias: str(o.alias),
|
||||
createdAt: num(o.createdAt),
|
||||
lastAccessed: num(o.lastAccessed),
|
||||
expiresAt: num(o.expiresAt),
|
||||
running: bool(o.running),
|
||||
terminalSessionActive: bool(o.terminalSessionActive),
|
||||
detachedTmuxSession: bool(o.detachedTmuxSession),
|
||||
health: str(o.health),
|
||||
tmux: {
|
||||
alias: str(tmux.alias),
|
||||
exists: bool(tmux.exists),
|
||||
attachedClients: num(tmux.attachedClients),
|
||||
windows: num(tmux.windows),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
tmux: {
|
||||
enabled: bool(tmuxRuntime.enabled),
|
||||
sessionsDetected: num(tmuxRuntime.sessionsDetected),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchHostStats(signal?: AbortSignal): Promise<HostStatsPayload> {
|
||||
const res = await fetch("/api/host-stats", { cache: "no-store", signal });
|
||||
const json = await responseJsonObject(res);
|
||||
if (!res.ok) throw new Error(String(json.error ?? "Failed to load host stats"));
|
||||
return parseHostStatsPayload(json);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Parse a fetch Response body as JSON. Empty bodies become `{}` so callers avoid
|
||||
* `res.json()` throwing "Unexpected end of JSON input" on 5xx with no body.
|
||||
*/
|
||||
export async function responseJsonObject(res: Response): Promise<Record<string, unknown>> {
|
||||
const text = await res.text();
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
const v = JSON.parse(trimmed) as unknown;
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return {};
|
||||
return v as Record<string, unknown>;
|
||||
} catch {
|
||||
const hint = trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed;
|
||||
throw new Error(`Invalid JSON from server (HTTP ${res.status}): ${hint}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
export type ManOption = {
|
||||
flag: string;
|
||||
meaning: string;
|
||||
};
|
||||
|
||||
export type ManExample = {
|
||||
caption?: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type ManSeeAlso = {
|
||||
name: string;
|
||||
section?: number;
|
||||
};
|
||||
|
||||
export type ManPage = {
|
||||
key: string;
|
||||
name: string;
|
||||
section: number;
|
||||
title: string;
|
||||
synopsis: string[];
|
||||
description?: string;
|
||||
options: ManOption[];
|
||||
files: string[];
|
||||
keywords: string[];
|
||||
examples: ManExample[];
|
||||
seeAlso: ManSeeAlso[];
|
||||
bareOsNotes?: string;
|
||||
listCategory: string;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
export type ManIndex = {
|
||||
schemaVersion?: number;
|
||||
generatedAt?: string;
|
||||
pages: ManPage[];
|
||||
categories: string[];
|
||||
pageByKey: Map<string, ManPage>;
|
||||
pagesByName: Map<string, ManPage[]>;
|
||||
pagesByCategory: Map<string, ManPage[]>;
|
||||
};
|
||||
|
||||
const nameCollator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 1): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizePage(value: unknown): ManPage | null {
|
||||
const page = asRecord(value);
|
||||
if (!page) return null;
|
||||
|
||||
const name = asString(page.name).trim();
|
||||
const section = asNumber(page.section, 1);
|
||||
if (!name) return null;
|
||||
|
||||
const options = Array.isArray(page.options)
|
||||
? page.options
|
||||
.map((option): ManOption | null => {
|
||||
const record = asRecord(option);
|
||||
if (!record) return null;
|
||||
const flag = asString(record.flag).trim();
|
||||
const meaning = asString(record.meaning).trim();
|
||||
if (!flag && !meaning) return null;
|
||||
return { flag, meaning };
|
||||
})
|
||||
.filter((option): option is ManOption => option !== null)
|
||||
: [];
|
||||
|
||||
const examples = Array.isArray(page.examples)
|
||||
? page.examples
|
||||
.map((example): ManExample | null => {
|
||||
const record = asRecord(example);
|
||||
if (!record) return null;
|
||||
const code = asString(record.code).trim();
|
||||
const caption = asString(record.caption).trim();
|
||||
if (!code) return null;
|
||||
return { code, ...(caption ? { caption } : {}) };
|
||||
})
|
||||
.filter((example): example is ManExample => example !== null)
|
||||
: [];
|
||||
|
||||
const seeAlso = Array.isArray(page.seeAlso)
|
||||
? page.seeAlso
|
||||
.map((entry): ManSeeAlso | null => {
|
||||
const record = asRecord(entry);
|
||||
if (!record) return null;
|
||||
const relatedName = asString(record.name).trim();
|
||||
if (!relatedName) return null;
|
||||
const relatedSection =
|
||||
typeof record.section === "number" && Number.isFinite(record.section) ? record.section : undefined;
|
||||
return {
|
||||
name: relatedName,
|
||||
...(relatedSection ? { section: relatedSection } : {}),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is ManSeeAlso => entry !== null)
|
||||
: [];
|
||||
|
||||
const listCategory = asString(page.listCategory).trim() || "uncategorized";
|
||||
const title = asString(page.title).trim() || `${name} (${section})`;
|
||||
|
||||
return {
|
||||
key: `${name}.${section}`,
|
||||
name,
|
||||
section,
|
||||
title,
|
||||
synopsis: asStringArray(page.synopsis),
|
||||
description: asString(page.description).trim() || undefined,
|
||||
options,
|
||||
files: asStringArray(page.files),
|
||||
keywords: asStringArray(page.keywords),
|
||||
examples,
|
||||
seeAlso,
|
||||
bareOsNotes: asString(page.bareOsNotes).trim() || undefined,
|
||||
listCategory,
|
||||
searchText: [name, title, ...asStringArray(page.keywords), asString(page.description)]
|
||||
.join(" ")
|
||||
.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseManIndex(value: unknown): ManIndex {
|
||||
const root = asRecord(value);
|
||||
if (!root) throw new Error("Invalid man index: expected JSON object.");
|
||||
|
||||
const rawPages = Array.isArray(root.pages) ? root.pages : [];
|
||||
const pages = rawPages.map(normalizePage).filter((page): page is ManPage => page !== null);
|
||||
pages.sort((a, b) => {
|
||||
const byName = nameCollator.compare(a.name, b.name);
|
||||
if (byName !== 0) return byName;
|
||||
if (a.section !== b.section) return a.section - b.section;
|
||||
return nameCollator.compare(a.key, b.key);
|
||||
});
|
||||
|
||||
const categorySet = new Set<string>();
|
||||
const pageByKey = new Map<string, ManPage>();
|
||||
const pagesByName = new Map<string, ManPage[]>();
|
||||
const pagesByCategory = new Map<string, ManPage[]>();
|
||||
for (const page of pages) categorySet.add(page.listCategory);
|
||||
for (const page of pages) {
|
||||
pageByKey.set(page.key, page);
|
||||
|
||||
const byName = pagesByName.get(page.name) ?? [];
|
||||
byName.push(page);
|
||||
pagesByName.set(page.name, byName);
|
||||
|
||||
const byCategory = pagesByCategory.get(page.listCategory) ?? [];
|
||||
byCategory.push(page);
|
||||
pagesByCategory.set(page.listCategory, byCategory);
|
||||
}
|
||||
const categories = Array.from(categorySet).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return {
|
||||
schemaVersion: typeof root.schemaVersion === "number" ? root.schemaVersion : undefined,
|
||||
generatedAt: asString(root.generatedAt).trim() || undefined,
|
||||
pages,
|
||||
categories,
|
||||
pageByKey,
|
||||
pagesByName,
|
||||
pagesByCategory,
|
||||
};
|
||||
}
|
||||
|
||||
export function searchManPages(index: ManIndex, query: string, category = "all"): ManPage[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const sourcePages = category === "all" ? index.pages : (index.pagesByCategory.get(category) ?? []);
|
||||
|
||||
return sourcePages.filter((page) => {
|
||||
if (!needle) return true;
|
||||
return page.searchText.includes(needle);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** Merge Tailwind classes safely (shadcn-style). */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
/** Runs as soon as the bundle loads — if you never see this, DevTools is on the wrong tab or the wrong origin (use the Vite dev URL in dev, not only the API port). */
|
||||
if (typeof window !== "undefined" && typeof console !== "undefined") {
|
||||
const log = console.info ?? console.log;
|
||||
log.call(console, "[BareCloud] client bundle loaded", {
|
||||
mode: import.meta.env.MODE,
|
||||
href: window.location.href,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const basename = (import.meta.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, "") || undefined;
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={basename}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
Clock3,
|
||||
Cpu,
|
||||
Eraser,
|
||||
RotateCcw,
|
||||
ShieldOff,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { KeepAliveButton } from "@/components/KeepAliveButton";
|
||||
import { Terminal } from "@/components/Terminal";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { forgetBooterId, rememberBooterId } from "@/lib/booter-storage";
|
||||
import { barecloudDebug } from "@/lib/barecloud-debug";
|
||||
import { responseJsonObject } from "@/lib/json-response";
|
||||
import { appWsClient, wsMode } from "@/lib/app-ws";
|
||||
|
||||
type StatusPayload = {
|
||||
id: string;
|
||||
running: boolean;
|
||||
uptimeSec: number | null;
|
||||
startedAt: string | null;
|
||||
lastAccessed: number;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
terminalSessionActive: boolean;
|
||||
detachedTmuxSession: boolean;
|
||||
};
|
||||
|
||||
function numField(v: unknown): number {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function numOrNullField(v: unknown): number | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() !== "") {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function strOrNullField(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
function statusPayloadFromJson(json: Record<string, unknown>): StatusPayload {
|
||||
return {
|
||||
id: typeof json.id === "string" ? json.id : "",
|
||||
running: Boolean(json.running),
|
||||
uptimeSec: numOrNullField(json.uptimeSec),
|
||||
startedAt: strOrNullField(json.startedAt),
|
||||
lastAccessed: numField(json.lastAccessed),
|
||||
createdAt: numField(json.createdAt),
|
||||
expiresAt: numField(json.expiresAt),
|
||||
terminalSessionActive: Boolean(json.terminalSessionActive),
|
||||
detachedTmuxSession: Boolean(json.detachedTmuxSession),
|
||||
};
|
||||
}
|
||||
|
||||
export function BooterPage() {
|
||||
const { id = "" } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<StatusPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [clearingCorestore, setClearingCorestore] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||||
/** Increment after restart / storage clear so `<Terminal>` remounts with a fresh session. */
|
||||
const [terminalResetKey, setTerminalResetKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (sp.get("new") !== "1") return;
|
||||
rememberBooterId(id);
|
||||
toast.success("Save this link — you will need it to open your instance again.");
|
||||
const base = (import.meta.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, "");
|
||||
window.history.replaceState({}, "", `${base}/booter/${encodeURIComponent(id)}`);
|
||||
}, [id]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const statusUrl = `/api/status/${encodeURIComponent(id)}?touch=1`;
|
||||
const res = await fetch(statusUrl, { cache: "no-store" });
|
||||
const json = await responseJsonObject(res);
|
||||
if (!res.ok) throw new Error(String(json.error ?? "Failed to load status"));
|
||||
const payload = statusPayloadFromJson(json);
|
||||
barecloudDebug("BooterPage /api/status", {
|
||||
routeBooterId: id,
|
||||
responseStatus: res.status,
|
||||
statusUrl,
|
||||
serverId: payload.id,
|
||||
idMatchesRoute: payload.id === id,
|
||||
running: payload.running,
|
||||
terminalSessionActive: payload.terminalSessionActive,
|
||||
detachedTmuxSession: payload.detachedTmuxSession,
|
||||
});
|
||||
setStatus(payload);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Status refresh failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if (wsMode() === "multiplex") {
|
||||
const off = appWsClient().subscribe(
|
||||
"booter.status",
|
||||
(payload) => {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
|
||||
const next = statusPayloadFromJson(payload as Record<string, unknown>);
|
||||
if (!next.id || next.id !== id) return;
|
||||
setStatus(next);
|
||||
setLoading(false);
|
||||
},
|
||||
{ booterId: id },
|
||||
);
|
||||
return () => off();
|
||||
}
|
||||
const t = window.setInterval(() => void refresh(), 5000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [refresh, id]);
|
||||
|
||||
const uptimeLabel =
|
||||
status?.terminalSessionActive
|
||||
? "Console connected"
|
||||
: status?.detachedTmuxSession
|
||||
? "Session saved — open the console to continue"
|
||||
: status?.running && typeof status.uptimeSec === "number"
|
||||
? (() => {
|
||||
const total = status.uptimeSec;
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
})()
|
||||
: status?.running
|
||||
? "Starting…"
|
||||
: "Not running";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col overflow-y-auto overflow-x-hidden bg-[#05070c] lg:h-dvh lg:max-h-dvh lg:overflow-hidden">
|
||||
<header className="shrink-0 border-b border-white/10 bg-black/35 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-[1400px] flex-wrap items-center justify-between gap-3 px-4 py-3 sm:gap-4 sm:px-5 sm:py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild variant="secondary" size="sm" className="rounded-xl">
|
||||
<Link to="/">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Home
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight">Bare OS instance</h1>
|
||||
{status?.running ? (
|
||||
<Badge variant="success">
|
||||
<Activity className="mr-1 inline h-3 w-3" />
|
||||
{status.terminalSessionActive ? "Live" : "Running"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning">
|
||||
<Cpu className="mr-1 inline h-3 w-3" />
|
||||
Not running
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate font-mono text-xs text-white/50">{id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto">
|
||||
<KeepAliveButton booterId={id} onExtended={() => void refresh()} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-[1600px] flex-1 flex-col gap-4 overflow-visible px-3 py-3 sm:px-4 sm:py-4 lg:flex-row lg:items-stretch lg:gap-5 lg:overflow-hidden lg:px-5 lg:py-5">
|
||||
<section className="flex min-h-[min(58dvh,680px)] min-w-0 flex-1 flex-col gap-3 overflow-hidden lg:min-h-0">
|
||||
<div className="flex shrink-0 flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold tracking-tight text-white">Console</h2>
|
||||
<p className="text-xs text-white/55">
|
||||
This is your live Bare OS session. Type here as you would in any terminal; resize the window if you need
|
||||
more space.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="default" className="w-fit shrink-0">
|
||||
Interactive
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
<Terminal key={`${id}-${terminalResetKey}`} booterId={id} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="flex min-h-0 w-full min-w-0 shrink-0 flex-col gap-3 overflow-x-hidden overflow-y-visible lg:max-h-[calc(100dvh-5.75rem)] lg:h-full lg:w-[300px] lg:overflow-y-auto lg:overflow-x-hidden lg:pr-1">
|
||||
<Card className="min-w-0 shrink-0">
|
||||
<CardHeader className="space-y-2 p-4 pb-2 sm:p-5 sm:pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-sky-300" />
|
||||
Lifecycle
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs leading-snug text-white/58 sm:text-sm">
|
||||
Kept for seven days after each extend. Tap <span className="text-white/75">Extend seven more days</span>{" "}
|
||||
before the deadline or this instance and its data are removed.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2.5 p-4 pt-0 text-sm text-white/70 sm:p-5 sm:pt-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock3 className="mt-0.5 h-4 w-4 text-white/45" />
|
||||
<div>
|
||||
<div className="text-xs text-white/45">Last activity</div>
|
||||
<div className="text-white/85">
|
||||
{loading || !status ? "…" : formatDistanceToNow(status.lastAccessed, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-start gap-2">
|
||||
<Activity className="mt-0.5 h-4 w-4 text-white/45" />
|
||||
<div>
|
||||
<div className="text-xs text-white/45">Status</div>
|
||||
<div className="text-white/85">{loading ? "…" : uptimeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-start gap-2">
|
||||
<CalendarClock className="mt-0.5 h-4 w-4 text-amber-300/80" />
|
||||
<div>
|
||||
<div className="text-xs text-white/45">Keep until</div>
|
||||
<div className="text-white/85">
|
||||
{loading || !status ? (
|
||||
"…"
|
||||
) : (
|
||||
<>
|
||||
{format(status.expiresAt, "yyyy-MM-dd HH:mm")}
|
||||
<span className="block text-[11px] text-white/50">
|
||||
({formatDistanceToNow(status.expiresAt, { addSuffix: true })})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="text-xs text-white/45">Created</div>
|
||||
<div className="font-mono text-xs text-white/75">
|
||||
{loading || !status ? "…" : format(status.createdAt, "yyyy-MM-dd HH:mm")}
|
||||
</div>
|
||||
{!loading && status?.id && status.id !== id ? (
|
||||
<p className="mt-2 text-xs leading-snug text-amber-200/95">
|
||||
The server returned a different instance id than this page URL. Refresh the page; if the console
|
||||
still does not match the id above, check that{" "}
|
||||
<span className="font-mono">NEXT_PUBLIC_TERMINAL_WS_URL</span> includes{" "}
|
||||
<span className="font-mono">{"{id}"}</span>.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 shrink-0">
|
||||
<CardHeader className="space-y-2 p-4 pb-2 sm:p-5 sm:pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldOff className="h-4 w-4 shrink-0 text-indigo-300" />
|
||||
Controls
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs leading-snug text-white/58 sm:text-sm">
|
||||
Clear storage wipes this instance's Corestore data and reboots Bare OS on this link. Corestore blocks
|
||||
are encrypted on disk. Delete removes the instance entirely.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-4 pt-0 sm:p-5 sm:pt-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
disabled={restarting || clearingCorestore}
|
||||
title={
|
||||
status?.terminalSessionActive
|
||||
? "While the console is open, a brief disconnect is normal when you restart."
|
||||
: "Stops and starts the Bare OS process for this instance."
|
||||
}
|
||||
onClick={async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/booters/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restart" }),
|
||||
});
|
||||
const json = await responseJsonObject(res);
|
||||
if (!res.ok) throw new Error(String((json as { error?: string }).error ?? "Restart failed"));
|
||||
toast.success("Instance restarted");
|
||||
setTerminalResetKey((k) => k + 1);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Restart failed");
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className={`mr-2 h-4 w-4 ${restarting ? "animate-spin" : ""}`} />
|
||||
{restarting ? "Restarting…" : "Restart instance"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
disabled={clearingCorestore || restarting}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
Delete instance
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-amber-500/35 text-amber-100 hover:bg-amber-950/40 hover:text-amber-50"
|
||||
disabled={restarting || clearingCorestore}
|
||||
title="Removes all stored data for this instance and starts Bare OS again. Your link stays the same."
|
||||
onClick={() => setClearDialogOpen(true)}
|
||||
>
|
||||
<Eraser className={`mr-2 h-4 w-4 ${clearingCorestore ? "animate-pulse" : ""}`} />
|
||||
{clearingCorestore ? "Clearing…" : "Clear storage"}
|
||||
</Button>
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link to="/my-booters">Back to my instances</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Delete this instance?"
|
||||
description="This permanently removes the server-side instance and all of its data. Your browser may still remember the old link until you clear it from “My instances”."
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
confirmLabel="Delete permanently"
|
||||
cancelLabel="Keep instance"
|
||||
tone="destructive"
|
||||
onConfirm={async () => {
|
||||
const res = await fetch(`/api/booters/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((json as { error?: string }).error || "Delete failed");
|
||||
forgetBooterId(id);
|
||||
toast.success("Instance deleted");
|
||||
navigate("/my-booters");
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={clearDialogOpen}
|
||||
onOpenChange={setClearDialogOpen}
|
||||
title="Clear all storage?"
|
||||
description="This erases this instance's Corestore data and boots Bare OS fresh on the same link. Corestore blocks are encrypted on disk. It cannot be undone."
|
||||
icon={<Eraser className="h-5 w-5" />}
|
||||
confirmLabel="Clear storage"
|
||||
cancelLabel="Cancel"
|
||||
tone="warning"
|
||||
pending={clearingCorestore}
|
||||
onConfirm={async () => {
|
||||
setClearingCorestore(true);
|
||||
try {
|
||||
const res = await fetch(`/api/booters/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "clear_corestore" }),
|
||||
});
|
||||
const json = await responseJsonObject(res);
|
||||
if (!res.ok) throw new Error(String((json as { error?: string }).error ?? "Clear failed"));
|
||||
toast.success("Storage cleared");
|
||||
setTerminalResetKey((k) => k + 1);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Clear failed");
|
||||
throw e;
|
||||
} finally {
|
||||
setClearingCorestore(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ExternalLink, Gift, GitBranch, Terminal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
const DISCORD_INVITE = "https://join.discord-linux.com";
|
||||
|
||||
export function CommunityPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-8">
|
||||
<section className="rounded-3xl border border-white/10 bg-gradient-to-b from-white/[0.08] to-white/[0.02] p-6 shadow-2xl shadow-black/40 sm:p-8">
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-sky-300/90">Community</p>
|
||||
<h1 className="max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||
Build with people who love Linux, open source, and shipping real projects.
|
||||
</h1>
|
||||
<p className="max-w-3xl text-sm leading-relaxed text-white/70 sm:text-base">
|
||||
Join our Discord coding community to collaborate, ask questions, and help shape the Bare OS ecosystem. It is open
|
||||
to everyone, whether you are just getting started or already deep in systems work.
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Button asChild size="lg" className="rounded-2xl px-7">
|
||||
<a href={DISCORD_INVITE} target="_blank" rel="noopener noreferrer">
|
||||
Join the Discord server
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Gift className="h-4 w-4 text-emerald-300" />
|
||||
Free Linux hosting
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Community members can access real Linux hosting at no cost for learning, building, and testing.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-white/70">
|
||||
Build in public, share your experiments, and get feedback from peers who actively run Linux workloads.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<GitBranch className="h-4 w-4 text-violet-300" />
|
||||
Git server access
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
The server is where you can register for our git platform and start contributing to Bare OS.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-white/70">
|
||||
Get set up, publish your work, and open contributions with guidance from maintainers and other contributors.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Terminal className="h-4 w-4 text-sky-300" />
|
||||
Coding-first community
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Discussions stay practical: troubleshooting, architecture, tooling, and project collaboration.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm leading-relaxed text-white/70">
|
||||
If you want to learn faster and contribute to meaningful open-source infrastructure, this is the place.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowRight, BookOpen, ExternalLink, Shield, TerminalSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const BARE_OS_REPO = "https://git.ssh.surf/snxraven/bare-operating-system";
|
||||
|
||||
type Accent = "sky" | "emerald" | "violet";
|
||||
|
||||
const accentBar: Record<Accent, string> = {
|
||||
sky: "from-sky-400 via-cyan-300/70 to-transparent",
|
||||
emerald: "from-emerald-400 via-teal-300/70 to-transparent",
|
||||
violet: "from-violet-400 via-indigo-300/70 to-transparent",
|
||||
};
|
||||
|
||||
const accentIcon: Record<Accent, string> = {
|
||||
sky: "bg-sky-500/[0.12] text-sky-200 ring-1 ring-sky-400/20 shadow-[0_0_28px_-6px_rgba(56,189,248,0.45)]",
|
||||
emerald: "bg-emerald-500/[0.12] text-emerald-200 ring-1 ring-emerald-400/20 shadow-[0_0_28px_-6px_rgba(52,211,153,0.4)]",
|
||||
violet: "bg-violet-500/[0.12] text-violet-200 ring-1 ring-violet-400/25 shadow-[0_0_28px_-6px_rgba(167,139,250,0.35)]",
|
||||
};
|
||||
|
||||
function HomeFeatureCard({
|
||||
accent,
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
foot,
|
||||
}: {
|
||||
accent: Accent;
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
foot: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="group relative flex min-h-0 min-w-0 flex-col overflow-hidden rounded-2xl border border-white/[0.12] bg-gradient-to-b from-white/[0.08] to-white/[0.02] shadow-lg shadow-black/30 backdrop-blur-xl transition-[border-color,box-shadow] duration-300 hover:border-white/[0.18] hover:shadow-xl hover:shadow-black/40">
|
||||
<div
|
||||
className={`pointer-events-none h-1 w-full shrink-0 bg-gradient-to-r ${accentBar[accent]} opacity-90`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4 sm:p-5">
|
||||
<div
|
||||
className={`inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl transition-transform duration-300 group-hover:scale-[1.03] ${accentIcon[accent]}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-h-0 space-y-2">
|
||||
<h3 className="text-base font-semibold tracking-tight text-white sm:text-lg">{title}</h3>
|
||||
<p className="text-xs leading-relaxed text-white/65 sm:text-sm sm:leading-relaxed">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-white/[0.06] bg-black/25 px-4 py-3 sm:px-5">
|
||||
<p className="text-[11px] leading-snug text-white/55 sm:text-xs sm:leading-relaxed">{foot}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col gap-3 sm:h-full sm:gap-5 md:gap-6">
|
||||
<section className="relative min-h-0 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] p-4 shadow-2xl shadow-black/40 backdrop-blur-xl sm:rounded-3xl sm:p-7 md:p-9">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(600px_300px_at_20%_0%,rgba(56,189,248,0.25),transparent_60%)]" />
|
||||
<div className="relative flex h-full min-h-0 flex-col gap-3 sm:gap-5">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<Badge variant="success">Free to use</Badge>
|
||||
<Badge variant="default">No sign-up</Badge>
|
||||
<Badge variant="warning">Extend every 7 days</Badge>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-visible sm:flex-none sm:space-y-0">
|
||||
<h1 className="line-clamp-3 max-w-3xl text-balance text-3xl font-semibold tracking-tight text-white sm:line-clamp-none sm:text-4xl md:text-5xl">
|
||||
Bare OS in your browser — a peer-to-peer operating system, ready when you are.
|
||||
</h1>
|
||||
<p className="line-clamp-4 max-w-2xl text-sm leading-relaxed text-white/70 sm:line-clamp-none sm:text-base">
|
||||
<span className="text-white/85">Bare OS</span> is a system image that lives on a Hyperdrive and reaches the world
|
||||
through Hyperswarm — built for decentralized distribution with Pear.{" "}
|
||||
<span className="text-white/85">BareCloud</span> hosts personal instances you open from a link: each one keeps its
|
||||
own state, boots over the network, and must be extended at least every seven days or it is removed automatically.
|
||||
</p>
|
||||
<p className="pt-1">
|
||||
<a
|
||||
href={BARE_OS_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-medium text-sky-200/95 transition-colors hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-white sm:text-sm"
|
||||
>
|
||||
<span className="truncate">Bare OS — source & docs on Git</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 opacity-70" aria-hidden />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Button asChild size="lg" className="rounded-2xl px-6 text-base sm:px-8">
|
||||
<Link to="/launch">
|
||||
Start an instance
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary" size="lg" className="rounded-2xl">
|
||||
<Link to="/my-booters">My instances</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary" size="lg" className="rounded-2xl">
|
||||
<Link to="/run-locally">Run The OS Locally</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary" size="lg" className="rounded-2xl">
|
||||
<Link to="/user-manual">User Manual</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-3 sm:gap-4 md:min-h-0 md:flex-1 md:grid-cols-3 md:grid-rows-1 md:gap-5">
|
||||
<HomeFeatureCard
|
||||
accent="sky"
|
||||
icon={<TerminalSquare className="h-5 w-5" strokeWidth={1.75} />}
|
||||
title="Real console"
|
||||
body="A full interactive session in the browser — type commands, resize the window, copy output, and work the way you would in a local terminal from your own machine."
|
||||
foot="Best on a laptop or desktop. Your session is private to the link you were given."
|
||||
/>
|
||||
<HomeFeatureCard
|
||||
accent="emerald"
|
||||
icon={<Shield className="h-5 w-5" strokeWidth={1.75} />}
|
||||
title="Isolated instance"
|
||||
body="Every link is its own Bare OS environment with separate Corestore storage, and blocks are encrypted on disk. Nothing is shared between instances."
|
||||
foot="There are no accounts: you keep the URL. If you lose it, we cannot recover access for you."
|
||||
/>
|
||||
<HomeFeatureCard
|
||||
accent="violet"
|
||||
icon={<BookOpen className="h-5 w-5" strokeWidth={1.75} />}
|
||||
title="Fair use"
|
||||
body="After seven days without an extension, instances are removed automatically so capacity stays available for everyone and new users can keep launching links without waiting."
|
||||
foot='Open your instance before the deadline and tap "Extend seven more days" to add another week.'
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { barecloudDebug } from "@/lib/barecloud-debug";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function LaunchPage() {
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* `POST /api/booters` with JSON body. Optional `NEXT_PUBLIC_PEAR_BOOT_LINK` pins the Pear app link at launch
|
||||
* (otherwise the server uses `BARECLOUD_PEAR_BOOT_LINK` / its default). Each instance stores its link in SQLite.
|
||||
*/
|
||||
async function launch() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const pearBootLink = import.meta.env.NEXT_PUBLIC_PEAR_BOOT_LINK?.trim();
|
||||
const launchBody = pearBootLink ? { pearBootLink } : {};
|
||||
barecloudDebug("LaunchPage POST /api/booters", {
|
||||
launchBody,
|
||||
hasPublicPearLink: Boolean(pearBootLink),
|
||||
});
|
||||
const res = await fetch("/api/booters", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(launchBody),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
retryAfterSec?: number;
|
||||
id?: string;
|
||||
};
|
||||
barecloudDebug("LaunchPage POST /api/booters response", {
|
||||
httpStatus: res.status,
|
||||
ok: res.ok,
|
||||
id: data.id,
|
||||
error: data.error,
|
||||
retryAfterSec: data.retryAfterSec,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const retry = data.retryAfterSec ? ` Try again in about ${data.retryAfterSec}s.` : "";
|
||||
toast.error(`${data.error ?? "Could not start an instance"}${retry}`);
|
||||
return;
|
||||
}
|
||||
if (data.id) {
|
||||
barecloudDebug("LaunchPage navigating to booter", { id: data.id });
|
||||
setStartOpen(false);
|
||||
navigate(`/booter/${encodeURIComponent(data.id)}?new=1`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Could not start an instance");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col gap-6 sm:gap-8">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Start a Bare OS instance</h1>
|
||||
<p className="text-sm leading-relaxed text-white/65">
|
||||
We create a fresh environment and open your console. Save the link you get next — it is the only key to this
|
||||
instance. Bare OS loads its image from peers on the network; if nothing answers in time, try again once seeders are
|
||||
available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ready when you are</CardTitle>
|
||||
<CardDescription>
|
||||
One click provisions your instance and takes you straight to the dashboard. No password and no email.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Button type="button" className="w-full sm:w-auto" disabled={submitting} onClick={() => setStartOpen(true)}>
|
||||
{submitting ? "Starting…" : "Start instance"}
|
||||
</Button>
|
||||
<Link className="text-center text-sm text-white/55 hover:text-white sm:text-left" to="/my-booters">
|
||||
Already have a link? Open my instances
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={startOpen} onOpenChange={(open) => !submitting && setStartOpen(open)}>
|
||||
<DialogContent
|
||||
className="max-w-[440px] sm:max-w-md"
|
||||
disableCloseButton={submitting}
|
||||
onPointerDownOutside={(e) => submitting && e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => submitting && e.preventDefault()}
|
||||
>
|
||||
<div className="flex gap-3 px-4 pb-1 pr-12 pt-5 sm:gap-4 sm:px-6 sm:pr-14 sm:pt-6">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-sky-500/30 bg-sky-950/40 text-sky-200">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-3 p-0 pr-0">
|
||||
<DialogTitle>Start a new instance?</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3 text-sm leading-relaxed text-white/65">
|
||||
<p className="text-white/70">
|
||||
We will provision a fresh Bare OS environment and take you to the live console.
|
||||
</p>
|
||||
<ul className="list-disc space-y-1.5 pl-4 text-white/60">
|
||||
<li>Save the URL you get next — it is the only key to this instance.</li>
|
||||
<li>There is no account; we only remember links in this browser if you revisit them.</li>
|
||||
<li>Images load from peers; if the network is quiet, try again in a minute.</li>
|
||||
<li>
|
||||
Each instance must be extended at least every seven days from the dashboard — otherwise it is removed
|
||||
automatically.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter className="mt-2">
|
||||
<Button type="button" variant="secondary" disabled={submitting} onClick={() => setStartOpen(false)}>
|
||||
Not now
|
||||
</Button>
|
||||
<Button type="button" disabled={submitting} onClick={() => void launch()}>
|
||||
{submitting ? "Starting…" : "Start now"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link2Off, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { BooterCard, type BooterSummary } from "@/components/BooterCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { forgetBooterId, getStoredBooterIds } from "@/lib/booter-storage";
|
||||
import { responseJsonObject } from "@/lib/json-response";
|
||||
import { appWsClient, wsMode } from "@/lib/app-ws";
|
||||
|
||||
export function MyBootersPage() {
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [booters, setBooters] = useState<BooterSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clearSavedOpen, setClearSavedOpen] = useState(false);
|
||||
const [forgetId, setForgetId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIds(getStoredBooterIds());
|
||||
}, []);
|
||||
|
||||
const qs = useMemo(() => {
|
||||
if (ids.length === 0) return "";
|
||||
const p = new URLSearchParams();
|
||||
p.set("ids", ids.join(","));
|
||||
return `?${p.toString()}`;
|
||||
}, [ids]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function run() {
|
||||
if (wsMode() === "multiplex") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!qs) {
|
||||
setBooters([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/booters${qs}`, { cache: "no-store" });
|
||||
const json = await responseJsonObject(res);
|
||||
if (!res.ok) throw new Error(String(json.error ?? "Failed to load"));
|
||||
const rows = (json.booters ?? []) as BooterSummary[];
|
||||
if (!cancelled) setBooters(rows);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Could not refresh the list");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [qs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wsMode() !== "multiplex") return;
|
||||
if (ids.length === 0) {
|
||||
setBooters([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const off = appWsClient().subscribe(
|
||||
"booter.list",
|
||||
(payload) => {
|
||||
if (!Array.isArray(payload)) return;
|
||||
setBooters(payload as BooterSummary[]);
|
||||
setLoading(false);
|
||||
},
|
||||
{ ids },
|
||||
);
|
||||
return () => off();
|
||||
}, [ids]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">My instances</h1>
|
||||
<p className="max-w-2xl text-sm leading-relaxed text-white/65">
|
||||
Links you have opened on this browser are listed here automatically. There is no account — if you use another
|
||||
device or clear site data, paste your saved URL to add it again.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="secondary">
|
||||
<Link to="/launch">Start another instance</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-sm text-white/60 backdrop-blur">
|
||||
Loading your saved links…
|
||||
</div>
|
||||
) : ids.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-sm text-white/65 backdrop-blur">
|
||||
Nothing saved here yet. Start an instance and we will remember the link on this device.
|
||||
</div>
|
||||
) : booters.length === 0 ? (
|
||||
<div className="rounded-2xl border border-amber-400/25 bg-amber-950/35 px-6 py-10 text-center text-sm text-amber-50 backdrop-blur">
|
||||
These links are still on this device, but the server no longer has matching instances ({ids.length} saved). They may
|
||||
have expired after a period without activity. You can start a new instance whenever you like.
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setClearSavedOpen(true)}>
|
||||
Clear saved links
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5">
|
||||
{booters.map((b) => (
|
||||
<div key={b.id} className="flex flex-col gap-3">
|
||||
<BooterCard booter={b} />
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-xs text-white/45 hover:text-white"
|
||||
onClick={() => setForgetId(b.id)}
|
||||
>
|
||||
Forget this link on this device
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={clearSavedOpen}
|
||||
onOpenChange={setClearSavedOpen}
|
||||
title="Clear saved links?"
|
||||
description="This only removes URLs stored in this browser. It does not delete anything on the server. You can paste a link again later if you still have it."
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
confirmLabel="Clear from this device"
|
||||
cancelLabel="Keep links"
|
||||
tone="warning"
|
||||
onConfirm={async () => {
|
||||
ids.forEach((x) => forgetBooterId(x));
|
||||
setIds([]);
|
||||
toast.message("Removed saved links from this browser");
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={forgetId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setForgetId(null);
|
||||
}}
|
||||
title="Forget this link?"
|
||||
description={
|
||||
forgetId ? (
|
||||
<>
|
||||
Remove{" "}
|
||||
<span className="rounded-md bg-white/[0.08] px-1.5 py-0.5 font-mono text-[11px] text-white/80">
|
||||
{forgetId}
|
||||
</span>{" "}
|
||||
from saved links on this device only. The instance on the server is unchanged.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
icon={<Link2Off className="h-5 w-5" />}
|
||||
confirmLabel="Forget on this device"
|
||||
cancelLabel="Cancel"
|
||||
tone="default"
|
||||
onConfirm={async () => {
|
||||
if (!forgetId) return;
|
||||
forgetBooterId(forgetId);
|
||||
setIds(getStoredBooterIds());
|
||||
toast.message("Removed from this browser only");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const LAST_UPDATED = "April 24, 2026";
|
||||
|
||||
export function PrivacyPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<section className="rounded-3xl border border-white/10 bg-gradient-to-b from-white/[0.08] to-white/[0.02] p-6 shadow-2xl shadow-black/40 sm:p-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-sky-300/90">Legal</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">Privacy Policy</h1>
|
||||
<p className="mt-3 text-sm text-white/70 sm:text-base">
|
||||
Last updated: {LAST_UPDATED}. This Privacy Policy explains how BareCloud handles information when you use our
|
||||
platform.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-white/10 bg-black/25 p-5 sm:p-6">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.14em] text-white/70">Contents</h2>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-sky-200 sm:grid-cols-2">
|
||||
<a href="#scope" className="hover:text-white">1. Scope</a>
|
||||
<a href="#data-collected" className="hover:text-white">2. Information We Collect</a>
|
||||
<a href="#how-used" className="hover:text-white">3. How We Use Information</a>
|
||||
<a href="#local-storage" className="hover:text-white">4. Local Browser Storage</a>
|
||||
<a href="#retention" className="hover:text-white">5. Retention and Deletion</a>
|
||||
<a href="#sharing" className="hover:text-white">6. Sharing and Third Parties</a>
|
||||
<a href="#security" className="hover:text-white">7. Security</a>
|
||||
<a href="#choices" className="hover:text-white">8. Your Choices</a>
|
||||
<a href="#children" className="hover:text-white">9. Children's Privacy</a>
|
||||
<a href="#transfers" className="hover:text-white">10. International Use</a>
|
||||
<a href="#changes" className="hover:text-white">11. Policy Changes</a>
|
||||
<a href="#contact" className="hover:text-white">12. Contact</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="scope" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">1. Scope</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
This Privacy Policy applies to BareCloud website, APIs, and related instance-hosting services. It describes the
|
||||
handling of technical and operational data associated with your use of the platform.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="data-collected" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">2. Information We Collect</h2>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
<li>Technical request metadata, including IP-related headers used for operation and abuse controls.</li>
|
||||
<li>Instance identifiers and lifecycle timestamps needed to run and manage your instance.</li>
|
||||
<li>Terminal session and websocket operational metadata (for connection/session control).</li>
|
||||
<li>Service logs and diagnostics used for reliability, troubleshooting, and security operations.</li>
|
||||
</ul>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud currently does not rely on traditional account registration for access to instances.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="how-used" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">3. How We Use Information</h2>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
<li>Operate and deliver instance lifecycle workflows (launch, status, extension, restart, deletion).</li>
|
||||
<li>Apply fair-use controls and launch-rate limiting.</li>
|
||||
<li>Maintain platform integrity, reliability, and abuse prevention.</li>
|
||||
<li>Diagnose and remediate service issues.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="local-storage" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">4. Local Browser Storage</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud may store instance identifiers in your browser local storage to help you quickly reopen previously used
|
||||
instances. This local storage is controlled by your browser and device, and you can clear it at any time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="retention" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">5. Retention and Deletion</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud applies retention windows to instances and associated operational records. Instances not extended in time
|
||||
may be removed automatically by scheduled cleanup. Manual deletion or data-clearing actions may also remove related
|
||||
runtime data.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="sharing" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">6. Sharing and Third Parties</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
We do not sell personal information. BareCloud may interact with external infrastructure providers and upstream
|
||||
content hosts to deliver product features (for example, upstream man page resource retrieval). Information may be
|
||||
disclosed if required by law, legal process, or to protect platform security.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="security" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">7. Security</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud uses technical and operational safeguards to reduce risk, including input validation, session controls, and
|
||||
service monitoring. No system can guarantee absolute security, so you should avoid storing highly sensitive data in
|
||||
transient instances unless you have your own protective controls.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="choices" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">8. Your Choices</h2>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
<li>You may delete instances using platform controls where available.</li>
|
||||
<li>You may clear browser local storage to remove locally remembered instance identifiers.</li>
|
||||
<li>You may stop using the service at any time.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="children" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">9. Children's Privacy</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud is not directed to children under 13. If you believe a child has provided information in a way that
|
||||
conflicts with applicable law, contact us and we will take reasonable steps to review and address the issue.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="transfers" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">10. International Use</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud may be accessed from multiple jurisdictions and may process information in infrastructure located outside
|
||||
your region. By using the service, you understand that data handling may occur in locations with different legal
|
||||
frameworks.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="changes" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">11. Policy Changes</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
We may update this Privacy Policy as the platform evolves. Revised versions are effective when posted with an updated
|
||||
date.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="contact" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">12. Contact</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
For privacy questions or requests, use BareCloud support/community contact channels listed on this website.
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
Related policy: <Link to="/terms" className="text-sky-200 hover:text-white">Terms of Service</Link>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Copy, TerminalSquare } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const LOCAL_RUN_COMMANDS = `npm i pear -g
|
||||
pear run pear://xc5xw4odfpd7txd1b3xr4u37rfpbjoqtb6zp3xxutgbbmrbtogio`;
|
||||
|
||||
export function RunLocallyPage() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(LOCAL_RUN_COMMANDS);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<section className="rounded-3xl border border-white/10 bg-gradient-to-b from-white/[0.08] to-white/[0.02] p-6 shadow-2xl shadow-black/40 sm:p-8">
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-sky-300/90">Run Bare OS locally</p>
|
||||
<h1 className="max-w-3xl text-3xl font-semibold tracking-tight text-white sm:text-4xl">
|
||||
Start Bare OS on your own machine with Pear.
|
||||
</h1>
|
||||
<p className="max-w-3xl text-sm leading-relaxed text-white/70 sm:text-base">
|
||||
Node.js is required and includes npm. After Node.js is installed, install Pear globally, then run the Bare OS
|
||||
peer URL directly from your terminal.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-white/10 bg-black/40 p-5 shadow-2xl shadow-black/35 sm:p-6">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium text-white/85">
|
||||
<TerminalSquare className="h-4 w-4 text-sky-300" />
|
||||
Terminal commands
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" className="rounded-xl" onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4" />
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-2xl border border-white/10 bg-black/60 p-4 text-sm text-sky-100">
|
||||
<code>{LOCAL_RUN_COMMANDS}</code>
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Activity, AlertCircle, Cpu, HardDrive, MemoryStick, Network } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { parseHostStatsPayload, type HostStatsPayload } from "@/lib/hostStats";
|
||||
import { appWsClient, wsMode } from "@/lib/app-ws";
|
||||
|
||||
function fmtBytes(v: number): string {
|
||||
if (!Number.isFinite(v) || v <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let value = v;
|
||||
let i = 0;
|
||||
while (value >= 1024 && i < units.length - 1) {
|
||||
value /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function fmtPct(v: number): string {
|
||||
return `${Number.isFinite(v) ? v.toFixed(1) : "0.0"}%`;
|
||||
}
|
||||
|
||||
function fmtAgo(ms: number): string {
|
||||
return ms > 0 ? formatDistanceToNow(ms, { addSuffix: true }) : "n/a";
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
title,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2 text-white/65">
|
||||
{icon}
|
||||
{title}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold text-white">{value}</div>
|
||||
<div className="mt-1 text-xs text-white/55">{hint}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPage() {
|
||||
const [stats, setStats] = useState<HostStatsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastOkAt, setLastOkAt] = useState<number | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (wsMode() === "multiplex") {
|
||||
const off = appWsClient().subscribe("host.stats", (payload) => {
|
||||
const parsed = parseHostStatsPayload((payload ?? {}) as Record<string, unknown>);
|
||||
setStats(parsed);
|
||||
setLastOkAt(Date.now());
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
});
|
||||
return () => off();
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer = 0;
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
const basePath = (import.meta.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, "");
|
||||
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const wsUrl = `${scheme}://${window.location.host}${basePath}/api/host-stats/ws`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0;
|
||||
setError(null);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(ev.data)) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.error === "string") {
|
||||
setError(obj.error);
|
||||
return;
|
||||
}
|
||||
const payload = parseHostStatsPayload(obj);
|
||||
setStats(payload);
|
||||
setLastOkAt(Date.now());
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Invalid host stats message");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setError("Host stats WebSocket error");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (closed) return;
|
||||
setError("Host stats disconnected, reconnecting...");
|
||||
const waitMs = Math.min(8000, 800 * Math.max(1, reconnectAttempt + 1));
|
||||
reconnectAttempt += 1;
|
||||
reconnectTimer = window.setTimeout(connect, waitMs);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) ws.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const stale = useMemo(() => {
|
||||
if (!lastOkAt) return false;
|
||||
return nowMs - lastOkAt > 15_000;
|
||||
}, [lastOkAt, nowMs]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Host Stats</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-white/65">
|
||||
Live BareCloud host telemetry with redacted instance/session identifiers. No access-capable ids or links are
|
||||
displayed on this page.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stats?.security.redacted ? <Badge variant="success">Redaction enabled</Badge> : <Badge variant="warning">Check redaction</Badge>}
|
||||
{stale ? <Badge variant="warning">Data stale</Badge> : <Badge variant="default">Live polling</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Card className="border-red-500/30">
|
||||
<CardContent className="flex items-center gap-3 p-5 text-red-100">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>{error}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<SummaryCard
|
||||
title="CPU"
|
||||
value={loading || !stats ? "..." : fmtPct(stats.host.cpu.usagePct)}
|
||||
hint={loading || !stats ? "Loading..." : `${stats.host.cpu.cores} cores · load1 ${stats.host.cpu.loadAvg.one.toFixed(2)}`}
|
||||
icon={<Cpu className="h-4 w-4" />}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Memory"
|
||||
value={loading || !stats ? "..." : fmtPct(stats.host.memory.usedPct)}
|
||||
hint={loading || !stats ? "Loading..." : `${fmtBytes(stats.host.memory.usedBytes)} / ${fmtBytes(stats.host.memory.totalBytes)}`}
|
||||
icon={<MemoryStick className="h-4 w-4" />}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Instances"
|
||||
value={loading || !stats ? "..." : `${stats.instances.fleet.runningBooters}/${stats.instances.fleet.totalBooters}`}
|
||||
hint={loading || !stats ? "Loading..." : `${stats.instances.fleet.tmuxSessions} tmux sessions · ${stats.instances.fleet.terminalActive} terminal active`}
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System</CardTitle>
|
||||
<CardDescription>
|
||||
{loading || !stats
|
||||
? "Loading system metadata..."
|
||||
: `${stats.host.distro ?? stats.host.platform} ${stats.host.release} · ${stats.host.hostnameAlias}`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 text-sm text-white/80">
|
||||
<div>Uptime: {loading || !stats ? "..." : formatDistanceToNow(Date.now() - stats.host.uptimeSec * 1000)}</div>
|
||||
<div>Swap used: {loading || !stats ? "..." : `${fmtBytes(stats.host.memory.swapUsedBytes)} (${fmtPct(stats.host.memory.swapUsedPct)})`}</div>
|
||||
<div>Last update: {lastOkAt ? fmtAgo(lastOkAt) : "n/a"}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TMUX Runtime</CardTitle>
|
||||
<CardDescription>BareCloud session persistence is tmux-based.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 text-sm text-white/80">
|
||||
<div>Enabled: {loading || !stats ? "..." : stats.tmux.enabled ? "yes" : "no"}</div>
|
||||
<div>Sessions detected: {loading || !stats ? "..." : stats.tmux.sessionsDetected}</div>
|
||||
<div>
|
||||
Fleet tmux sessions: {loading || !stats ? "..." : `${stats.instances.fleet.tmuxSessions} active instance sessions`}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Filesystems
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(stats?.host.filesystems ?? []).slice(0, 10).map((fs) => (
|
||||
<div key={`${fs.mount}-${fs.fsType}`} className="rounded-lg border border-white/10 p-3 text-xs text-white/75">
|
||||
<div className="font-mono text-white/90">{fs.mount}</div>
|
||||
<div>{fs.fsType}</div>
|
||||
<div>
|
||||
{fmtBytes(fs.usedBytes)} / {fmtBytes(fs.sizeBytes)} ({fmtPct(fs.usedPct)})
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
Network Interfaces
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(stats?.host.network ?? []).slice(0, 10).map((iface) => (
|
||||
<div key={iface.alias} className="rounded-lg border border-white/10 p-3 text-xs text-white/75">
|
||||
<div className="font-mono text-white/90">{iface.alias}</div>
|
||||
<div>
|
||||
RX {fmtBytes(iface.rxRateBps)}/s · TX {fmtBytes(iface.txRateBps)}/s
|
||||
</div>
|
||||
<div>
|
||||
Errors {iface.rxErrors + iface.txErrors} · Drops {iface.rxDrops + iface.txDrops}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Booters & tmux sessions (redacted)</CardTitle>
|
||||
<CardDescription>Operational status only. Access-capable identifiers are intentionally hidden.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(stats?.instances.booters ?? []).slice(0, 30).map((b) => (
|
||||
<div key={b.alias} className="grid gap-2 rounded-xl border border-white/10 p-3 text-xs text-white/80 md:grid-cols-6">
|
||||
<div className="font-mono text-white/90">{b.alias}</div>
|
||||
<div>{b.running ? "running" : "inactive"}</div>
|
||||
<div>{b.terminalSessionActive ? "terminal attached" : b.detachedTmuxSession ? "detached tmux" : "no session"}</div>
|
||||
<div>{b.tmux.exists ? `${b.tmux.attachedClients} attached` : "tmux absent"}</div>
|
||||
<div>expires {fmtAgo(b.expiresAt)}</div>
|
||||
<div>last active {fmtAgo(b.lastAccessed)}</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Processes</CardTitle>
|
||||
<CardDescription>Sorted by CPU usage. Container runtime processes are excluded.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[680px] text-left text-xs text-white/80">
|
||||
<thead className="text-white/55">
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="py-2 pr-3 font-medium">Process</th>
|
||||
<th className="py-2 pr-3 font-medium">State</th>
|
||||
<th className="py-2 pr-3 font-medium">CPU</th>
|
||||
<th className="py-2 pr-3 font-medium">Memory</th>
|
||||
<th className="py-2 pr-0 font-medium">RSS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(stats?.host.topProcesses ?? []).map((p) => (
|
||||
<tr key={p.alias} className="border-b border-white/5 align-top">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="max-w-[340px] truncate text-white/90">{p.command || "unknown"}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className="rounded-md border border-white/15 bg-white/5 px-1.5 py-0.5 font-mono text-[11px]">
|
||||
{p.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-mono">{fmtPct(p.cpuPct)}</td>
|
||||
<td className="py-2 pr-3 font-mono">{fmtPct(p.memPct)}</td>
|
||||
<td className="py-2 pr-0 font-mono">{fmtBytes(p.rssBytes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const LAST_UPDATED = "April 24, 2026";
|
||||
|
||||
export function TermsPage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<section className="rounded-3xl border border-white/10 bg-gradient-to-b from-white/[0.08] to-white/[0.02] p-6 shadow-2xl shadow-black/40 sm:p-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-sky-300/90">Legal</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">Terms of Service</h1>
|
||||
<p className="mt-3 text-sm text-white/70 sm:text-base">
|
||||
Last updated: {LAST_UPDATED}. These Terms of Service govern your use of BareCloud and related Bare OS hosting
|
||||
services.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-white/10 bg-black/25 p-5 sm:p-6">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.14em] text-white/70">Contents</h2>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-sky-200 sm:grid-cols-2">
|
||||
<a href="#service" className="hover:text-white">1. Service Overview</a>
|
||||
<a href="#eligibility" className="hover:text-white">2. Eligibility</a>
|
||||
<a href="#acceptable-use" className="hover:text-white">3. Acceptable Use</a>
|
||||
<a href="#fair-use" className="hover:text-white">4. Fair Use and Limits</a>
|
||||
<a href="#instance-lifecycle" className="hover:text-white">5. Instance Lifecycle</a>
|
||||
<a href="#user-responsibilities" className="hover:text-white">6. User Responsibilities</a>
|
||||
<a href="#suspension" className="hover:text-white">7. Suspension and Termination</a>
|
||||
<a href="#disclaimers" className="hover:text-white">8. Disclaimers</a>
|
||||
<a href="#liability" className="hover:text-white">9. Limitation of Liability</a>
|
||||
<a href="#indemnity" className="hover:text-white">10. Indemnification</a>
|
||||
<a href="#law-venue" className="hover:text-white">11. Governing Law and Venue</a>
|
||||
<a href="#changes" className="hover:text-white">12. Changes to Terms</a>
|
||||
<a href="#contact" className="hover:text-white">13. Contact</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="service" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">1. Service Overview</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud provides browser-accessible Bare OS instances for community and development use. The service uses a
|
||||
link-based access model instead of traditional account authentication. By accessing or using the service, you agree
|
||||
to these Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="eligibility" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">2. Eligibility</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
You represent that you are legally able to enter into this agreement and to use the service in compliance with
|
||||
applicable law. If you use BareCloud on behalf of an organization, you represent that you are authorized to bind
|
||||
that organization.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="acceptable-use" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">3. Acceptable Use</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">You agree not to use BareCloud to:</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
<li>violate law, regulations, or third-party rights;</li>
|
||||
<li>distribute malware or conduct unauthorized intrusion, scanning, or abuse;</li>
|
||||
<li>interfere with service integrity, stability, or availability;</li>
|
||||
<li>use the platform in ways that unreasonably burden shared community resources.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="fair-use" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">4. Fair Use and Limits</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud uses fair-use controls, including launch throttling and operational safeguards. Launch requests may be
|
||||
temporarily limited (for example, within rolling time windows) to protect platform stability for all users.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="instance-lifecycle" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">5. Instance Lifecycle</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
Instances are not permanent by default. BareCloud applies retention windows and cleanup processes. Unless extended,
|
||||
instances may be removed automatically after the published retention period. Deletion and cleanup actions may remove
|
||||
associated runtime state and data.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="user-responsibilities" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">6. User Responsibilities</h2>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
<li>You are responsible for maintaining control of your instance links.</li>
|
||||
<li>You are responsible for actions performed through your links or active sessions.</li>
|
||||
<li>You should export or preserve any important work before retention deadlines.</li>
|
||||
<li>You should not rely on BareCloud as your sole backup or archival system.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="suspension" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">7. Suspension and Termination</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud may suspend, restrict, or terminate access (including specific instances) if we reasonably believe use is
|
||||
abusive, unlawful, harmful to platform operations, or otherwise violates these Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="disclaimers" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">8. Disclaimers</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
BareCloud is provided on an "as is" and "as available" basis. We do not guarantee uninterrupted
|
||||
availability, error-free operation, or fitness for any specific purpose. We may modify, pause, or discontinue parts
|
||||
of the service at any time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="liability" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">9. Limitation of Liability</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
To the maximum extent permitted by law, BareCloud and its operators are not liable for indirect, incidental,
|
||||
special, consequential, or punitive damages, or for loss of data, profits, or business opportunities arising from
|
||||
use of or inability to use the service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="indemnity" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">10. Indemnification</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
You agree to defend, indemnify, and hold harmless BareCloud and its operators from claims, losses, and expenses
|
||||
arising out of your use of the service or your violation of these Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="law-venue" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">11. Governing Law and Venue</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
These Terms are governed by the laws of the State of Georgia, without regard to conflict-of-laws principles. You
|
||||
agree that courts located in DeKalb County, Georgia are the exclusive venue for disputes arising out of or relating
|
||||
to these Terms or the service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="changes" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">12. Changes to Terms</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
We may update these Terms from time to time. Updated versions become effective when posted. Continued use of
|
||||
BareCloud after updates means you accept the revised Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="contact" className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-6">
|
||||
<h2 className="text-xl font-semibold text-white">13. Contact</h2>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
For legal or policy questions, contact BareCloud support through project-maintained community channels and the
|
||||
platform links published on this site.
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-white/75 sm:text-base">
|
||||
Related policy: <Link to="/privacy" className="text-sky-200 hover:text-white">Privacy Policy</Link>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly NEXT_PUBLIC_BASE_PATH?: string;
|
||||
readonly NEXT_PUBLIC_APP_ORIGIN?: string;
|
||||
readonly NEXT_PUBLIC_TERMINAL_WS_URL?: string;
|
||||
/** Optional `pear://…` sent as `pearBootLink` on `POST /api/booters` so new instances use your app key. */
|
||||
readonly NEXT_PUBLIC_PEAR_BOOT_LINK?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import path from "path";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const rawBase = (process.env.NEXT_PUBLIC_BASE_PATH ?? process.env.BARECLOUD_BASE_PATH ?? "").trim();
|
||||
const base = rawBase ? (rawBase.endsWith("/") ? rawBase : `${rawBase}/`) : "/";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, path.join(__dirname, ".."), "");
|
||||
const apiTarget = env.BARECLOUD_API_PROXY ?? `http://127.0.0.1:${env.PORT ?? "3000"}`;
|
||||
|
||||
return {
|
||||
root: __dirname,
|
||||
base,
|
||||
envDir: path.join(__dirname, ".."),
|
||||
envPrefix: ["VITE_", "NEXT_PUBLIC_"],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user