mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 10:05:18 +00:00
Add Foundry dev panel
This commit is contained in:
parent
859ad13934
commit
f365342dcc
4 changed files with 397 additions and 0 deletions
|
|
@ -26,6 +26,12 @@ export interface FoundryAppClient {
|
|||
resumeSubscription(organizationId: string): Promise<void>;
|
||||
reconnectGithub(organizationId: string): Promise<void>;
|
||||
recordSeatUsage(workspaceId: string): Promise<void>;
|
||||
setMockDebugOrganizationState?(input: {
|
||||
organizationId: string;
|
||||
githubSyncStatus?: "pending" | "syncing" | "synced" | "error";
|
||||
githubInstallationStatus?: "connected" | "install_required" | "reconnect_required";
|
||||
runtimeStatus?: "healthy" | "error";
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateFoundryAppClientOptions {
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@ export interface MockFoundryAppClient {
|
|||
resumeSubscription(organizationId: string): Promise<void>;
|
||||
reconnectGithub(organizationId: string): Promise<void>;
|
||||
recordSeatUsage(workspaceId: string): void;
|
||||
setMockDebugOrganizationState(input: {
|
||||
organizationId: string;
|
||||
githubSyncStatus?: MockGithubSyncStatus;
|
||||
githubInstallationStatus?: MockGithubInstallationStatus;
|
||||
runtimeStatus?: MockActorRuntimeStatus;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "sandbox-agent-foundry:mock-app:v1";
|
||||
|
|
@ -174,6 +180,22 @@ function buildHealthyRuntimeState(): MockFoundryActorRuntimeState {
|
|||
};
|
||||
}
|
||||
|
||||
function buildSampleRuntimeIssue(organization: MockFoundryOrganization): MockFoundryActorRuntimeIssue {
|
||||
return {
|
||||
actorId: `${organization.id}:github-state`,
|
||||
actorType: "organization",
|
||||
scopeId: organization.id,
|
||||
scopeLabel: `${organization.settings.displayName} organization`,
|
||||
message: "GitHub sync failed while refreshing repositories",
|
||||
workflowId: "github-sync",
|
||||
stepName: "full-sync",
|
||||
attempt: 2,
|
||||
willRetry: true,
|
||||
retryDelayMs: 5000,
|
||||
occurredAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
||||
return {
|
||||
auth: {
|
||||
|
|
@ -671,6 +693,50 @@ class MockFoundryAppStore implements MockFoundryAppClient {
|
|||
}));
|
||||
}
|
||||
|
||||
async setMockDebugOrganizationState(input: {
|
||||
organizationId: string;
|
||||
githubSyncStatus?: MockGithubSyncStatus;
|
||||
githubInstallationStatus?: MockGithubInstallationStatus;
|
||||
runtimeStatus?: MockActorRuntimeStatus;
|
||||
}): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(input.organizationId);
|
||||
this.updateOrganization(input.organizationId, (organization) => {
|
||||
const nextIssues =
|
||||
input.runtimeStatus === "error" ? (organization.runtime.issues.length > 0 ? organization.runtime.issues : [buildSampleRuntimeIssue(organization)]) : [];
|
||||
const nextRuntime =
|
||||
input.runtimeStatus != null
|
||||
? {
|
||||
...organization.runtime,
|
||||
status: input.runtimeStatus,
|
||||
errorCount: nextIssues.length,
|
||||
lastErrorAt: nextIssues[0]?.occurredAt ?? null,
|
||||
issues: nextIssues,
|
||||
}
|
||||
: organization.runtime;
|
||||
const nextSyncStatus = input.githubSyncStatus ?? organization.github.syncStatus;
|
||||
return {
|
||||
...organization,
|
||||
github: {
|
||||
...organization.github,
|
||||
syncStatus: nextSyncStatus,
|
||||
installationStatus: input.githubInstallationStatus ?? organization.github.installationStatus,
|
||||
importedRepoCount: nextSyncStatus === "synced" ? organization.repoCatalog.length : organization.github.importedRepoCount,
|
||||
lastSyncLabel:
|
||||
nextSyncStatus === "syncing"
|
||||
? "Syncing repositories..."
|
||||
: nextSyncStatus === "error"
|
||||
? "GitHub sync failed"
|
||||
: nextSyncStatus === "pending"
|
||||
? "Waiting to sync"
|
||||
: "Synced just now",
|
||||
lastSyncAt: nextSyncStatus === "synced" ? Date.now() : organization.github.lastSyncAt,
|
||||
},
|
||||
runtime: nextRuntime,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
recordSeatUsage(workspaceId: string): void {
|
||||
const org = this.snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId);
|
||||
const currentUser = currentMockUser(this.snapshot);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { type ReactNode, useEffect } from "react";
|
|||
import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client";
|
||||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { DevPanel } from "../components/dev-panel";
|
||||
import { MockLayout } from "../components/mock-layout";
|
||||
import {
|
||||
MockAccountSettingsPage,
|
||||
|
|
@ -344,6 +345,7 @@ function RootLayout() {
|
|||
<>
|
||||
<RouteContextSync />
|
||||
<Outlet />
|
||||
<DevPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
323
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
323
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouterState } from "@tanstack/react-router";
|
||||
import { Bug, RefreshCw, ShieldAlert, Wifi } from "lucide-react";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
import { isMockFrontendClient } from "../lib/env";
|
||||
import { activeMockOrganization, activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
||||
|
||||
const DEV_PANEL_STORAGE_KEY = "sandbox-agent-foundry:dev-panel-visible";
|
||||
|
||||
function readStoredVisibility(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const stored = window.localStorage.getItem(DEV_PANEL_STORAGE_KEY);
|
||||
return stored == null ? true : stored === "true";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredVisibility(value: boolean): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(DEV_PANEL_STORAGE_KEY, String(value));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function sectionStyle(borderColor: string, background: string) {
|
||||
return {
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
padding: "12px",
|
||||
borderRadius: "12px",
|
||||
border: `1px solid ${borderColor}`,
|
||||
background,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function labelStyle(color: string) {
|
||||
return {
|
||||
fontSize: "11px",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase" as const,
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
export function DevPanel() {
|
||||
if (!import.meta.env.DEV) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const client = useMockAppClient();
|
||||
const snapshot = useMockAppSnapshot();
|
||||
const organization = activeMockOrganization(snapshot);
|
||||
const user = activeMockUser(snapshot);
|
||||
const organizations = eligibleOrganizations(snapshot);
|
||||
const t = useFoundryTokens();
|
||||
const location = useRouterState({ select: (state) => state.location });
|
||||
const [visible, setVisible] = useState<boolean>(() => readStoredVisibility());
|
||||
|
||||
useEffect(() => {
|
||||
writeStoredVisibility(visible);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.shiftKey && event.key.toLowerCase() === "d") {
|
||||
event.preventDefault();
|
||||
setVisible((current) => !current);
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
const modeLabel = isMockFrontendClient ? "Mock" : "Live";
|
||||
const github = organization?.github ?? null;
|
||||
const runtime = organization?.runtime ?? null;
|
||||
const runtimeSummary = useMemo(() => {
|
||||
if (!runtime || runtime.errorCount === 0) {
|
||||
return "No actor errors";
|
||||
}
|
||||
return runtime.errorCount === 1 ? "1 actor error" : `${runtime.errorCount} actor errors`;
|
||||
}, [runtime]);
|
||||
|
||||
const pillButtonStyle = useCallback(
|
||||
(active = false) =>
|
||||
({
|
||||
border: `1px solid ${active ? t.accent : t.borderDefault}`,
|
||||
background: active ? t.surfacePrimary : t.surfaceSecondary,
|
||||
color: t.textPrimary,
|
||||
borderRadius: "999px",
|
||||
padding: "6px 10px",
|
||||
fontSize: "11px",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}) as const,
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!visible) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(true)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: "16px",
|
||||
bottom: "16px",
|
||||
zIndex: 1000,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
background: t.surfacePrimary,
|
||||
color: t.textPrimary,
|
||||
borderRadius: "999px",
|
||||
padding: "10px 14px",
|
||||
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.28)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Bug size={14} />
|
||||
Dev
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: "16px",
|
||||
bottom: "16px",
|
||||
width: "360px",
|
||||
maxHeight: "calc(100vh - 32px)",
|
||||
overflowY: "auto",
|
||||
zIndex: 1000,
|
||||
borderRadius: "18px",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
background: t.surfacePrimary,
|
||||
color: t.textPrimary,
|
||||
boxShadow: "0 24px 60px rgba(0, 0, 0, 0.35)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "14px 16px",
|
||||
borderBottom: `1px solid ${t.borderDefault}`,
|
||||
background: t.surfacePrimary,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: "2px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<Bug size={14} />
|
||||
<strong style={{ fontSize: "13px" }}>Dev Panel</strong>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: t.textMuted,
|
||||
}}
|
||||
>
|
||||
{modeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: t.textMuted }}>{location.pathname}</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => setVisible(false)} style={pillButtonStyle()}>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "12px", padding: "14px" }}>
|
||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||
<div style={labelStyle(t.textMuted)}>Session</div>
|
||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||
<div>Auth: {snapshot.auth.status}</div>
|
||||
<div>User: {user ? `${user.name} (@${user.githubLogin})` : "None"}</div>
|
||||
<div>Organization: {organization?.settings.displayName ?? "None selected"}</div>
|
||||
</div>
|
||||
{isMockFrontendClient ? (
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
{snapshot.auth.status === "signed_in" ? (
|
||||
<button type="button" onClick={() => void client.signOut()} style={pillButtonStyle()}>
|
||||
Sign out
|
||||
</button>
|
||||
) : (
|
||||
snapshot.users.map((candidate) => (
|
||||
<button key={candidate.id} type="button" onClick={() => void client.signInWithGithub(candidate.id)} style={pillButtonStyle()}>
|
||||
Sign in as {candidate.githubLogin}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||
<div style={labelStyle(t.textMuted)}>GitHub</div>
|
||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||
<div>Installation: {github?.installationStatus ?? "n/a"}</div>
|
||||
<div>Sync: {github?.syncStatus ?? "n/a"}</div>
|
||||
<div>Repos: {github?.importedRepoCount ?? 0}</div>
|
||||
<div>Last sync: {github?.lastSyncLabel ?? "n/a"}</div>
|
||||
</div>
|
||||
{organization ? (
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={pillButtonStyle()}>
|
||||
<RefreshCw size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||
Sync
|
||||
</button>
|
||||
<button type="button" onClick={() => void client.reconnectGithub(organization.id)} style={pillButtonStyle()}>
|
||||
<Wifi size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{isMockFrontendClient && organization && client.setMockDebugOrganizationState ? (
|
||||
<div style={{ display: "grid", gap: "8px" }}>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
||||
{(["pending", "syncing", "synced", "error"] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubSyncStatus: status })}
|
||||
style={pillButtonStyle(github?.syncStatus === status)}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
||||
{(["connected", "install_required", "reconnect_required"] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubInstallationStatus: status })}
|
||||
style={pillButtonStyle(github?.installationStatus === status)}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||
<div style={labelStyle(t.textMuted)}>Runtime</div>
|
||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||
<div>Status: {runtime?.status ?? "n/a"}</div>
|
||||
<div>{runtimeSummary}</div>
|
||||
{runtime?.issues[0] ? <div>Latest: {runtime.issues[0].message}</div> : null}
|
||||
</div>
|
||||
{organization ? (
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
{isMockFrontendClient && client.setMockDebugOrganizationState ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "error" })}
|
||||
style={pillButtonStyle(runtime?.status === "error")}
|
||||
>
|
||||
<ShieldAlert size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||
Show error
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "healthy" })}
|
||||
style={pillButtonStyle(runtime?.status === "healthy")}
|
||||
>
|
||||
Healthy
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{runtime?.errorCount ? (
|
||||
<button type="button" onClick={() => void client.clearOrganizationRuntimeIssues(organization.id)} style={pillButtonStyle()}>
|
||||
Clear actor errors
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isMockFrontendClient && organizations.length > 0 ? (
|
||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||
<div style={labelStyle(t.textMuted)}>Mock Organization</div>
|
||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
||||
{organizations.map((candidate) => (
|
||||
<button
|
||||
key={candidate.id}
|
||||
type="button"
|
||||
onClick={() => void client.selectOrganization(candidate.id)}
|
||||
style={pillButtonStyle(organization?.id === candidate.id)}
|
||||
>
|
||||
{candidate.settings.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue