feat: #1686 add Display tab unavailable state #1701
@@ -9,6 +9,7 @@ import { DetailsTab } from "./tabs/DetailsTab";
|
||||
import { SkillsTab } from "./tabs/SkillsTab";
|
||||
import { ChatTab } from "./tabs/ChatTab";
|
||||
import { ConfigTab } from "./tabs/ConfigTab";
|
||||
import { DisplayTab } from "./tabs/DisplayTab";
|
||||
import { TerminalTab } from "./tabs/TerminalTab";
|
||||
import { FilesTab } from "./tabs/FilesTab";
|
||||
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
|
||||
@@ -31,6 +32,7 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [
|
||||
{ id: "details", label: "Details", icon: "◉" },
|
||||
{ id: "skills", label: "Plugins", icon: "✦" },
|
||||
{ id: "terminal", label: "Terminal", icon: "▸" },
|
||||
{ id: "display", label: "Display", icon: "▣" },
|
||||
{ id: "config", label: "Config", icon: "⚙" },
|
||||
{ id: "schedule", label: "Schedule", icon: "⏲" },
|
||||
{ id: "channels", label: "Channels", icon: "⇌" },
|
||||
@@ -300,6 +302,7 @@ export function SidePanel() {
|
||||
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
|
||||
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
|
||||
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
|
||||
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
|
||||
vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => null }));
|
||||
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
|
||||
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
|
||||
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
|
||||
@@ -74,7 +75,7 @@ import { SidePanel } from "../SidePanel";
|
||||
|
||||
const TABS = [
|
||||
"chat", "activity", "details", "skills", "terminal",
|
||||
"config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
|
||||
"display", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
|
||||
];
|
||||
|
||||
describe("SidePanel — ARIA tablist pattern", () => {
|
||||
@@ -85,10 +86,15 @@ describe("SidePanel — ARIA tablist pattern", () => {
|
||||
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
|
||||
});
|
||||
|
||||
it("renders exactly 13 tab buttons", () => {
|
||||
it("renders exactly 14 tab buttons", () => {
|
||||
render(<SidePanel />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs.length).toBe(13);
|
||||
expect(tabs.length).toBe(14);
|
||||
});
|
||||
|
||||
it("renders the Display tab", () => {
|
||||
render(<SidePanel />);
|
||||
expect(document.getElementById("tab-display")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("active tab (chat) has aria-selected='true'", () => {
|
||||
@@ -99,11 +105,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
|
||||
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("all other 12 tabs have aria-selected='false'", () => {
|
||||
it("all other 13 tabs have aria-selected='false'", () => {
|
||||
render(<SidePanel />);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
const inactive = tabs.filter((t) => t.id !== "tab-chat");
|
||||
expect(inactive.length).toBe(12);
|
||||
expect(inactive.length).toBe(13);
|
||||
for (const tab of inactive) {
|
||||
expect(tab.getAttribute("aria-selected")).toBe("false");
|
||||
}
|
||||
@@ -116,7 +122,7 @@ describe("SidePanel — ARIA tablist pattern", () => {
|
||||
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
|
||||
expect(zeros.length).toBe(1);
|
||||
expect(zeros[0].id).toBe("tab-chat");
|
||||
expect(minusOnes.length).toBe(12);
|
||||
expect(minusOnes.length).toBe(13);
|
||||
});
|
||||
|
||||
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface DisplayStatus {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
protocol?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function DisplayTab({ workspaceId }: Props) {
|
||||
const [status, setStatus] = useState<DisplayStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
api
|
||||
.get<DisplayStatus>(`/workspaces/${workspaceId}/display`)
|
||||
.then((data) => {
|
||||
if (!cancelled) setStatus(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Display status unavailable");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-950/20 p-4">
|
||||
<h3 className="text-sm font-medium text-red-200">Display status unavailable</h3>
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-red-200/75">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="h-24 rounded-lg border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status.available) {
|
||||
const isNotEnabled = status.reason === "display_not_enabled";
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center bg-surface-sunken/30 p-8 text-center">
|
||||
<svg
|
||||
width="72"
|
||||
height="72"
|
||||
viewBox="0 0 72 72"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="mb-4 text-ink-mid"
|
||||
>
|
||||
<rect x="12" y="14" width="48" height="36" rx="4" stroke="currentColor" strokeWidth="2.5" opacity="0.65" />
|
||||
<path d="M28 58h16M36 50v8M16 16l40 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<h3 className="mb-1.5 text-sm font-medium text-ink">
|
||||
{isNotEnabled ? "Display is not enabled for this workspace." : "Display session is not ready."}
|
||||
</h3>
|
||||
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
|
||||
{isNotEnabled
|
||||
? "Recreate this workspace with display enabled to view and take over its desktop."
|
||||
: "This workspace has display configuration, but the desktop session infrastructure is not configured yet."}
|
||||
</p>
|
||||
{!isNotEnabled && (
|
||||
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
|
||||
<dt className="text-ink-mid">Mode</dt>
|
||||
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
|
||||
<dt className="text-ink-mid">Status</dt>
|
||||
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: mockGet,
|
||||
},
|
||||
}));
|
||||
|
||||
import { DisplayTab } from "../DisplayTab";
|
||||
|
||||
describe("DisplayTab", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
it("renders unavailable state for non-display workspaces", async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "display_not_enabled",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-no-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display");
|
||||
});
|
||||
});
|
||||
@@ -130,7 +130,7 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
deliveryMode?: string;
|
||||
}
|
||||
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -126,3 +127,50 @@ func withStoredCompute(ctx context.Context, workspaceID string, payload models.C
|
||||
payload.Compute = compute
|
||||
return payload
|
||||
}
|
||||
|
||||
// Display handles GET /workspaces/:id/display.
|
||||
//
|
||||
// Phase 1 only exposes the product contract and the non-display unavailable
|
||||
// state. Future desktop-control work will replace the display-enabled branch
|
||||
// with short-lived proxied DCV session details.
|
||||
func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
var raw string
|
||||
err := db.DB.QueryRowContext(c.Request.Context(),
|
||||
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&raw)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(404, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("Display: load compute for %s failed: %v", workspaceID, err)
|
||||
c.JSON(500, gin.H{"error": "failed to load display config"})
|
||||
return
|
||||
}
|
||||
var compute models.WorkspaceCompute
|
||||
if raw != "" && raw != "{}" {
|
||||
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
|
||||
log.Printf("Display: invalid compute JSON for %s: %v", workspaceID, err)
|
||||
c.JSON(500, gin.H{"error": "invalid display config"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
|
||||
c.JSON(200, gin.H{
|
||||
"available": false,
|
||||
"reason": "display_not_enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"available": false,
|
||||
"reason": "display_session_unavailable",
|
||||
"mode": compute.Display.Mode,
|
||||
"protocol": compute.Display.Protocol,
|
||||
"width": compute.Display.Width,
|
||||
"height": compute.Display.Height,
|
||||
"status": "not_configured",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -173,3 +174,37 @@ func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-no-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-no-display/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != false {
|
||||
t.Fatalf("available = %v, want false", resp["available"])
|
||||
}
|
||||
if resp["reason"] != "display_not_enabled" {
|
||||
t.Fatalf("reason = %v, want display_not_enabled", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,10 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// the tenant AWS credentials. Admin-gated because console output
|
||||
// can include user-data snippets we treat as semi-sensitive.
|
||||
wsAdmin.GET("/workspaces/:id/console", wh.Console)
|
||||
// Display sessions will eventually return short-lived proxied DCV
|
||||
// URLs, so keep the endpoint admin-gated from the first unavailable
|
||||
// state rather than widening it later.
|
||||
wsAdmin.GET("/workspaces/:id/display", wh.Display)
|
||||
|
||||
// Admin memory backup/restore (#1051) — bulk export/import of agent
|
||||
// memories for safe Docker rebuilds. Matches workspaces by name on import.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func buildWorkspaceDisplayEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
wh := handlers.NewWorkspaceHandler(nil, nil, "http://localhost:8080", t.TempDir())
|
||||
r.GET("/workspaces/:id/display", middleware.AdminAuth(db.DB), wh.Display)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplayRoute_RequiresAdminAuth(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller")
|
||||
mock := setupRouterTestDB(t)
|
||||
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
r := buildWorkspaceDisplayEngine(t)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/workspaces/ws-display/display", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for unauthenticated request, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock unmet: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user