mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Enable dev tool Dashboard tab via development-environment probe instead of env var (#1749)
This commit is contained in:
parent
f44d5d9bba
commit
d5ed20863a
@ -120,6 +120,16 @@ const nextConfig = {
|
||||
},
|
||||
|
||||
async headers() {
|
||||
// The development-environment (RDE) dashboard is embedded as an iframe by the
|
||||
// dev tool overlay, which runs inside the customer's own app on a *different*
|
||||
// localhost origin (e.g. http://localhost:3000). A plain X-Frame-Options:
|
||||
// SAMEORIGIN would block that framing, so RDE builds instead scope framing to
|
||||
// localhost origins via CSP frame-ancestors. This only applies to the RDE
|
||||
// build target (build:rde-standalone / dev:rde-production); the hosted and
|
||||
// self-host Docker builds keep X-Frame-Options: SAMEORIGIN.
|
||||
const isRdeBuild = process.env.HEXCLAVE_DASHBOARD_BUILD_FOR_RDE === "true";
|
||||
const allowsFraming = isRdeBuild || resolveHexclaveStackEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_PREVIEW", "NEXT_PUBLIC_STACK_IS_PREVIEW") === "true";
|
||||
const rdeFrameAncestors = "frame-ancestors 'self' http://localhost:* https://localhost:* http://*.localhost:* https://*.localhost:* http://127.0.0.1:* https://127.0.0.1:* http://[::1]:* https://[::1]:*";
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
@ -141,7 +151,7 @@ const nextConfig = {
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
...resolveHexclaveStackEnvVar("NEXT_PUBLIC_HEXCLAVE_IS_PREVIEW", "NEXT_PUBLIC_STACK_IS_PREVIEW") === "true" ? [] : [{
|
||||
...allowsFraming ? [] : [{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN",
|
||||
}],
|
||||
@ -149,7 +159,7 @@ const nextConfig = {
|
||||
key: "Content-Security-Policy",
|
||||
// Note: *.localhost requires Chrome 117+ and may not work in Firefox
|
||||
// without network.dns.localDomains configuration. Fine for dev tool purposes.
|
||||
value: "",
|
||||
value: isRdeBuild ? rdeFrameAncestors : "",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"bundle-type-definitions": "tsx scripts/bundle-type-definitions.ts",
|
||||
"bundle-type-definitions:watch": "tsx watch --clear-screen=false scripts/bundle-type-definitions.ts",
|
||||
"build": "rimraf .next/types .next/dev/types .next-development-environment/types .next-development-environment/dev/types && pnpm run bundle-type-definitions && next build",
|
||||
"build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT=true pnpm run build",
|
||||
"build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT=true HEXCLAVE_DASHBOARD_BUILD_FOR_RDE=true pnpm run build",
|
||||
"docker-build": "pnpm run bundle-type-definitions && next build --experimental-build-mode compile",
|
||||
"analyze-bundle": "next experimental-analyze",
|
||||
"start": "next start --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}01",
|
||||
|
||||
@ -41,6 +41,7 @@ runOrExit("pnpm", ["exec", "next", "build"], {
|
||||
NEXT_CONFIG_OUTPUT: "standalone",
|
||||
NODE_ENV: "production",
|
||||
STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT: "true",
|
||||
HEXCLAVE_DASHBOARD_BUILD_FOR_RDE: "true",
|
||||
},
|
||||
});
|
||||
|
||||
@ -56,6 +57,12 @@ const server = spawn(process.execPath, [standaloneServerPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
// `next.config`'s `headers()` is evaluated at build time and baked into the
|
||||
// routes manifest (the build above already sets this flag), so the RDE
|
||||
// frame-ancestors headers are correct regardless of the server's runtime
|
||||
// env. We still propagate the flag here so the running process self-reports
|
||||
// as an RDE build and stays correct if any header logic ever moves to runtime.
|
||||
HEXCLAVE_DASHBOARD_BUILD_FOR_RDE: "true",
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
@ -0,0 +1,129 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
let tempDir: string | undefined;
|
||||
const remoteDevelopmentEnvironmentEnabledEnv = "NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT";
|
||||
const knownProjectId = "known-project-id";
|
||||
const knownConfigPath = "/home/dev/app/hexclave.config.ts";
|
||||
|
||||
function useTempStateFile() {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "stack-rde-availability-"));
|
||||
process.env[remoteDevelopmentEnvironmentEnabledEnv] = "true";
|
||||
process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json");
|
||||
writeFileSync(process.env.STACK_DEV_ENVS_PATH, JSON.stringify({
|
||||
version: 1,
|
||||
localDashboardsByPort: {
|
||||
"26700": {
|
||||
port: 26700,
|
||||
secret: "secret",
|
||||
pid: 123,
|
||||
startedAtMillis: Date.now(),
|
||||
},
|
||||
},
|
||||
projectsByConfigPath: {
|
||||
[knownConfigPath]: {
|
||||
projectId: knownProjectId,
|
||||
teamId: "team-id",
|
||||
publishableClientKey: "pck",
|
||||
secretServerKey: "ssk",
|
||||
apiBaseUrl: "http://localhost:8102",
|
||||
updatedAtMillis: Date.now(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
chmodSync(process.env.STACK_DEV_ENVS_PATH, 0o600);
|
||||
}
|
||||
|
||||
function request(url: string, headers: Record<string, string>) {
|
||||
return new NextRequest(url, { headers });
|
||||
}
|
||||
|
||||
async function getResponse(req: NextRequest) {
|
||||
const { GET } = await import("./route");
|
||||
return await GET(req);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
delete process.env[remoteDevelopmentEnvironmentEnabledEnv];
|
||||
delete process.env.STACK_DEV_ENVS_PATH;
|
||||
if (tempDir != null) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
describe("development environment project-availability route", () => {
|
||||
it("returns 404 when the development environment is disabled", async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "stack-rde-availability-"));
|
||||
process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json");
|
||||
const response = await getResponse(request(
|
||||
`http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`,
|
||||
{ host: "127.0.0.1:26700" },
|
||||
));
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 403 for non-loopback hosts", async () => {
|
||||
useTempStateFile();
|
||||
const response = await getResponse(request(
|
||||
`http://preview.example.test/api/development-environment/project-availability?project_id=${knownProjectId}`,
|
||||
{ host: "preview.example.test" },
|
||||
));
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 403 for non-localhost origins", async () => {
|
||||
useTempStateFile();
|
||||
const response = await getResponse(request(
|
||||
`http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`,
|
||||
{ host: "127.0.0.1:26700", origin: "https://evil.example.com" },
|
||||
));
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("allows requests from a localhost origin (the customer's dev tool)", async () => {
|
||||
useTempStateFile();
|
||||
const response = await getResponse(request(
|
||||
`http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`,
|
||||
{ host: "127.0.0.1:26700", origin: "http://localhost:3000" },
|
||||
));
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ running: true, project_available: true });
|
||||
});
|
||||
|
||||
it("returns 400 when project_id is missing", async () => {
|
||||
useTempStateFile();
|
||||
const response = await getResponse(request(
|
||||
"http://127.0.0.1:26700/api/development-environment/project-availability",
|
||||
{ host: "127.0.0.1:26700" },
|
||||
));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("reports project_available: true for a project the development environment owns", async () => {
|
||||
useTempStateFile();
|
||||
const response = await getResponse(request(
|
||||
`http://127.0.0.1:26700/api/development-environment/project-availability?project_id=${knownProjectId}`,
|
||||
{ host: "127.0.0.1:26700" },
|
||||
));
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ running: true, project_available: true });
|
||||
});
|
||||
|
||||
it("reports project_available: false for an unknown project", async () => {
|
||||
useTempStateFile();
|
||||
const response = await getResponse(request(
|
||||
"http://127.0.0.1:26700/api/development-environment/project-availability?project_id=some-other-project",
|
||||
{ host: "127.0.0.1:26700" },
|
||||
));
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ running: true, project_available: false });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,58 @@
|
||||
import { getPublicEnvVar } from "@/lib/env";
|
||||
import { isLocalhost } from "@hexclave/shared/dist/utils/urls";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// The dev tool overlay runs inside the customer's own app, which lives on a
|
||||
// different localhost origin than the development-environment dashboard on
|
||||
// :26700. It therefore has no browser secret and must reach this endpoint
|
||||
// cross-origin. All this endpoint ever reveals is whether the locally-running
|
||||
// development environment already owns a given project id — never secrets,
|
||||
// config, or any other project data. Even so, the dashboard's global CORS
|
||||
// middleware sets `Access-Control-Allow-Origin: *` on every /api/ route, which
|
||||
// would otherwise let any website a developer visits read that ownership
|
||||
// boolean off their machine. We therefore gate on both:
|
||||
// - the request Host being loopback (defense-in-depth against proxied hosts;
|
||||
// the development environment only ever binds to loopback anyway), and
|
||||
// - the request Origin being absent or itself a localhost origin, so a
|
||||
// non-localhost page cannot read the response cross-origin.
|
||||
|
||||
function requestHostIsLoopback(req: NextRequest): boolean {
|
||||
const host = req.headers.get("host");
|
||||
if (host == null) return false;
|
||||
return isLocalhost(`http://${host}`);
|
||||
}
|
||||
|
||||
// A browser attaches an `Origin` header to every cross-origin (and most
|
||||
// same-origin) fetches. A missing Origin means the caller is not a browser
|
||||
// cross-origin request (e.g. a same-origin/non-browser client), which is fine.
|
||||
// If it is present, it must be a localhost origin — otherwise some remote site
|
||||
// is trying to read our loopback-only ownership boolean.
|
||||
function requestOriginIsAllowed(req: NextRequest): boolean {
|
||||
const origin = req.headers.get("origin");
|
||||
if (origin == null) return true;
|
||||
return isLocalhost(origin);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true";
|
||||
if (!isRemoteDevelopmentEnvironment) {
|
||||
return NextResponse.json({ error: "This endpoint is only available in development environments." }, { status: 404 });
|
||||
}
|
||||
if (!requestHostIsLoopback(req)) {
|
||||
return NextResponse.json({ error: "This endpoint is only available on loopback addresses." }, { status: 403 });
|
||||
}
|
||||
if (!requestOriginIsAllowed(req)) {
|
||||
return NextResponse.json({ error: "This endpoint is only available to localhost origins." }, { status: 403 });
|
||||
}
|
||||
|
||||
const projectId = req.nextUrl.searchParams.get("project_id");
|
||||
if (projectId == null || projectId.length === 0) {
|
||||
return NextResponse.json({ error: "Missing project_id query parameter." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { getRemoteDevelopmentEnvironmentProjectConfigPaths } = await import("@/lib/remote-development-environment/manager");
|
||||
const projectAvailable = getRemoteDevelopmentEnvironmentProjectConfigPaths().has(projectId);
|
||||
return NextResponse.json({ running: true, project_available: projectAvailable });
|
||||
}
|
||||
@ -5,7 +5,6 @@ import { DEV_TOOL_ROOT_ID } from "@hexclave/shared/dist/utils/dev-tool";
|
||||
import { runAsynchronously, runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { isLocalhost } from "@hexclave/shared/dist/utils/urls";
|
||||
import type { StackClientApp } from "../lib/hexclave-app";
|
||||
import { envVars } from "../generated/env";
|
||||
import { getGlobalUiInstance, h, hasAppendChild, setGlobalUiInstance, setHtml, type UiGlobalInstance } from "../in-page-ui/dom";
|
||||
import { getBaseUrl } from "../lib/hexclave-app/apps/implementations/common";
|
||||
import type { HandlerUrlOptions, HandlerUrls, HandlerUrlTarget } from "../lib/hexclave-app/common";
|
||||
@ -60,6 +59,11 @@ const MAX_LOG_ENTRIES = 500;
|
||||
const CONSOLE_LOG_BATCH_SIZE = 100;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
const DOCS_URL = 'https://docs.hexclave.com';
|
||||
// The local development-environment dashboard always listens on this loopback
|
||||
// port (see the CLI's DEFAULT_DASHBOARD_PORT). The dev tool runs inside the
|
||||
// customer's own app, so it can't read the CLI's env vars — the port is fixed.
|
||||
const LOCAL_DASHBOARD_ORIGIN = 'http://localhost:26700';
|
||||
const HOSTED_DASHBOARD_URL = 'https://app.hexclave.com';
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: string }[] = [
|
||||
{ id: 'overview', label: 'Overview', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>' },
|
||||
@ -190,42 +194,60 @@ function resolveApiBaseUrl(app: StackClientApp<true>): string {
|
||||
return getBaseUrl(opts.baseUrl);
|
||||
}
|
||||
|
||||
function shouldShowDashboardTab(app: StackClientApp<true>): boolean {
|
||||
return envVars.HEXCLAVE_IS_LOCAL_EMULATOR === "true" && isLocalhost(resolveApiBaseUrl(app));
|
||||
function localDashboardProjectUrl(app: StackClientApp<true>): string {
|
||||
return `${LOCAL_DASHBOARD_ORIGIN}/projects/${encodeURIComponent(app.projectId)}`;
|
||||
}
|
||||
|
||||
function getTabsForApp(app: StackClientApp<true>): { id: TabId; label: string; icon: string }[] {
|
||||
if (shouldShowDashboardTab(app)) {
|
||||
return TABS;
|
||||
}
|
||||
return TABS.filter((tab) => tab.id !== 'dashboard');
|
||||
function parseProjectAvailabilityResponse(body: unknown): boolean {
|
||||
if (typeof body !== 'object' || body === null) return false;
|
||||
return 'project_available' in body && body.project_available === true;
|
||||
}
|
||||
|
||||
function deriveDashboardBaseUrl(apiBaseUrl: string): string {
|
||||
// How long to wait for the availability probe before giving up. A refused
|
||||
// connection rejects almost immediately, but a host that accepts the socket yet
|
||||
// never responds would otherwise leave the tab stuck on "Checking…" forever.
|
||||
const LOCAL_DASHBOARD_PROBE_TIMEOUT_MS = 3000;
|
||||
|
||||
// Whether the current page can even talk to the HTTP-only local dashboard.
|
||||
// LOCAL_DASHBOARD_ORIGIN is `http://localhost:26700`, so the page itself must be
|
||||
// an http: localhost origin: an https: page (e.g. an HTTPS dev server) would
|
||||
// have both the probe fetch and the iframe embed blocked as mixed content, so
|
||||
// we treat that as "not available" and fall back to the hosted dashboard.
|
||||
function canReachLocalDashboard(): boolean {
|
||||
return isLocalhost(window.location.href) && window.location.protocol === 'http:';
|
||||
}
|
||||
|
||||
// Decides whether the Dashboard tab can embed the locally-running
|
||||
// development-environment dashboard. Rather than inferring from build-time env
|
||||
// vars (which don't reflect whether the dev environment is actually up right
|
||||
// now), we ask the running development environment directly:
|
||||
// 1. We must be viewing the app from an http: localhost origin — the HTTP-only
|
||||
// dashboard on :26700 is only reachable from, and only relevant to, a local
|
||||
// browser (and an https: page can't reach it without mixed-content errors).
|
||||
// 2. The development environment must be running AND already own this exact
|
||||
// project (see the project-availability route it answers).
|
||||
// Any failure — dev environment not running (connection refused), probe timeout,
|
||||
// project unknown, or a malformed response — resolves to `false`, which shows
|
||||
// the hosted-dashboard hint instead.
|
||||
async function isLocalDashboardProjectAvailable(app: StackClientApp<true>): Promise<boolean> {
|
||||
if (!canReachLocalDashboard()) return false;
|
||||
const url = `${LOCAL_DASHBOARD_ORIGIN}/api/development-environment/project-availability?project_id=${encodeURIComponent(app.projectId)}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), LOCAL_DASHBOARD_PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const url = new URL(apiBaseUrl);
|
||||
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]') {
|
||||
const port = url.port;
|
||||
if (port && port.endsWith('02')) {
|
||||
url.port = port.slice(0, -2) + '01';
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
if (url.hostname.startsWith('api.')) {
|
||||
url.hostname = 'app.' + url.hostname.slice(4);
|
||||
return url.origin;
|
||||
}
|
||||
return url.origin;
|
||||
const response = await fetch(url, { method: 'GET', signal: controller.signal });
|
||||
if (!response.ok) return false;
|
||||
return parseProjectAvailabilityResponse(await response.json());
|
||||
} catch {
|
||||
return 'https://app.hexclave.com';
|
||||
// Reachability probe: when the local development environment isn't running,
|
||||
// the fetch rejects (connection refused); when it hangs, the AbortController
|
||||
// aborts it. Either way that simply means "not available".
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDashboardUrl(app: StackClientApp<true>): string {
|
||||
const base = deriveDashboardBaseUrl(resolveApiBaseUrl(app));
|
||||
return `${base}/projects/${encodeURIComponent(app.projectId)}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
@ -560,8 +582,8 @@ function createTabBar(
|
||||
// Iframe helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026', errorMsg = 'Unable to load content', errorDetail?: string, openExternallyLabel?: string): HTMLElement {
|
||||
const container = h('div', { className: 'sdt-iframe-container' });
|
||||
function populateIframeTab(container: HTMLElement, src: string, title: string, loadingMsg = 'Loading\u2026', errorMsg = 'Unable to load content', errorDetail?: string, openExternallyLabel?: string): void {
|
||||
container.innerHTML = '';
|
||||
if (openExternallyLabel != null) {
|
||||
container.appendChild(h('div', { className: 'sdt-iframe-toolbar' },
|
||||
h('a', { href: src, target: '_blank', rel: 'noopener noreferrer', className: 'sdt-iframe-open-link' }, openExternallyLabel),
|
||||
@ -591,7 +613,7 @@ function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026
|
||||
}
|
||||
const retryBtn = h('button', { className: 'sdt-iframe-error-btn' }, 'Retry');
|
||||
retryBtn.addEventListener('click', () => {
|
||||
container.replaceWith(createIframeTab(src, title, loadingMsg, errorMsg, errorDetail, openExternallyLabel));
|
||||
populateIframeTab(container, src, title, loadingMsg, errorMsg, errorDetail, openExternallyLabel);
|
||||
});
|
||||
errDiv.appendChild(retryBtn);
|
||||
const link = h('a', { href: src, target: '_blank', rel: 'noopener noreferrer', style: { color: 'var(--sdt-accent)', fontSize: '12px', textDecoration: 'none' } }, 'Open in new tab');
|
||||
@ -600,7 +622,6 @@ function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026
|
||||
});
|
||||
|
||||
container.appendChild(iframe);
|
||||
return container;
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
@ -1678,8 +1699,52 @@ function createAITab(app: StackClientApp<true>): HTMLElement {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createDashboardTab(app: StackClientApp<true>): HTMLElement {
|
||||
const dashboardUrl = resolveDashboardUrl(app);
|
||||
return createIframeTab(dashboardUrl, 'Hexclave Dashboard', 'Loading dashboard\u2026', 'Unable to load dashboard', 'The dashboard may require authentication or block framing', 'Open in New Tab');
|
||||
const container = h('div', { className: 'sdt-iframe-container' });
|
||||
|
||||
function showChecking() {
|
||||
container.innerHTML = '';
|
||||
container.appendChild(h('div', { className: 'sdt-iframe-loading' }, 'Checking development environment\u2026'));
|
||||
}
|
||||
|
||||
function showUnavailable() {
|
||||
container.innerHTML = '';
|
||||
const message = h('div', { className: 'sdt-dashboard-unavailable' });
|
||||
const text = h('div', { className: 'sdt-dashboard-unavailable-text' });
|
||||
text.appendChild(document.createTextNode('Navigate to '));
|
||||
text.appendChild(h('a', {
|
||||
className: 'sdt-dashboard-unavailable-link',
|
||||
href: HOSTED_DASHBOARD_URL,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
}, HOSTED_DASHBOARD_URL));
|
||||
text.appendChild(document.createTextNode(" to view this project's dashboard"));
|
||||
message.appendChild(text);
|
||||
container.appendChild(message);
|
||||
}
|
||||
|
||||
async function check() {
|
||||
showChecking();
|
||||
const available = await isLocalDashboardProjectAvailable(app);
|
||||
// The dev tool may have been torn down while we were probing; only mutate
|
||||
// the DOM if this container is still mounted.
|
||||
if (!container.isConnected) return;
|
||||
if (available) {
|
||||
populateIframeTab(
|
||||
container,
|
||||
localDashboardProjectUrl(app),
|
||||
'Hexclave Dashboard',
|
||||
'Loading dashboard\u2026',
|
||||
'Unable to load dashboard',
|
||||
'The dashboard may require authentication or block framing',
|
||||
'Open in New Tab',
|
||||
);
|
||||
} else {
|
||||
showUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
runAsynchronously(check);
|
||||
return container;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -2109,7 +2174,7 @@ function createPanel(
|
||||
panel.style.height = state.get().panelHeight + 'px';
|
||||
}
|
||||
|
||||
const tabs = getTabsForApp(app);
|
||||
const tabs = TABS;
|
||||
const storedActiveTab = state.get().activeTab;
|
||||
const activeTab = tabs.some((tab) => tab.id === storedActiveTab) ? storedActiveTab : DEFAULT_STATE.activeTab;
|
||||
|
||||
|
||||
@ -324,6 +324,14 @@ export const devToolCSS = getInPageUiBaseCSS('.hexclave-devtool') + `
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Iframe panes lay their content out as a flex column so the embedded
|
||||
dashboard (or the fallback message) fills the pane height. Uses two
|
||||
classes to win over the plain .sdt-tab-pane-active { display: block }. */
|
||||
.hexclave-devtool .sdt-tab-pane-iframe.sdt-tab-pane-active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ===== Overview tab — single column ===== */
|
||||
|
||||
.hexclave-devtool .sdt-ov {
|
||||
@ -1231,6 +1239,33 @@ export const devToolCSS = getInPageUiBaseCSS('.hexclave-devtool') + `
|
||||
background: var(--sdt-accent-hover);
|
||||
}
|
||||
|
||||
.hexclave-devtool .sdt-dashboard-unavailable {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--sdt-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hexclave-devtool .sdt-dashboard-unavailable-text {
|
||||
max-width: 420px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hexclave-devtool .sdt-dashboard-unavailable-link {
|
||||
color: var(--sdt-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hexclave-devtool .sdt-dashboard-unavailable-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Shared content fade animation */
|
||||
.hexclave-devtool .sdt-tab-content-fade {
|
||||
animation: sdt-tab-fade-in 0.15s ease-out;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user