From a92ca08502111f77066bfcc45fc4134045b815c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Fri, 31 Jul 2026 22:49:12 +0200 Subject: [PATCH 1/3] fix: streamline dev setup DX with auto-sourced env config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DX improvements: - dev-env.sh writes scripts/.env.dev with all config (including OPENSHELL_DIR) - Makefile auto-sources scripts/.env.dev via -include (zero manual exports) - make dev-full: one command starts infra + dashboard - make dev: auto-reads config from previous dev-env.sh start OPENSHELL_DIR resolution: - Checks env var first, then scripts/.env.dev, then prompts interactively - Offers to clone NVIDIA/OpenShell if no checkout exists - Persists the path to .env.dev for future runs Robust shutdown: - Graceful stop with 10s timeout, then SIGKILL - Detects and kills orphaned gateway processes on the expected port - Force-removes Keycloak containers regardless of state - Cleans up stale PID/log/config files (preserves PKI, DB, env config) Also: fix grpcurl to use proto descriptor instead of server reflection Assisted-By: πŸ€– Claude Code --- .gitignore | 3 +- Makefile | 8 ++- README.md | 35 ++++++----- scripts/dev-env.sh | 148 ++++++++++++++++++++++++++++++++++++++------- 4 files changed, 156 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index 73da34f..674576c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,10 @@ backend/bin/ .idea/ .vscode/ -# Dev environment state (generated certs, runtime artifacts) +# Dev environment state (generated certs, runtime artifacts, env config) scripts/.pki/ scripts/.state/ +scripts/.env.dev # Env files are never committed .env diff --git a/Makefile b/Makefile index 5c5db3f..665752c 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,10 @@ PROTO_GRPC_OPTS := \ --go-grpc_opt=Minference.proto=$(GO_MODULE)/gen/inferencev1 \ --go-grpc_opt=Mopenshell.proto=$(GO_MODULE)/gen/openshellv1 +# Auto-source dev environment config if available (written by scripts/dev-env.sh) +-include scripts/.env.dev +export + .PHONY: setup proto dev dev-full dev-backend dev-frontend build build-frontend build-backend test lint typecheck clean setup: ## Install frontend deps and Go deps @@ -32,7 +36,7 @@ proto: ## Regenerate Go stubs from backend/proto/*.proto into backend/gen/ $(addprefix $(PROTO_DIR)/,$(PROTO_FILES)) cd backend && go mod tidy -dev-full: ## Start dev infrastructure (Keycloak + gateway) then frontend + BFF +dev-full: ## Start Keycloak + gateway, then frontend + BFF (one command) ./scripts/dev-env.sh start @$(MAKE) dev @@ -40,7 +44,7 @@ dev: ## Start frontend dev server (:3000) and Go BFF (:8080) @$(MAKE) -j2 dev-backend dev-frontend dev-backend: - cd backend && AUTH_DISABLED=$${AUTH_DISABLED:-true} go run ./cmd/server + cd backend && go run ./cmd/server dev-frontend: cd frontend && npm start diff --git a/README.md b/README.md index c0a1593..c3750f9 100644 --- a/README.md +++ b/README.md @@ -36,21 +36,13 @@ To test with real OIDC authentication against a local Keycloak and OpenShell gat ```bash make setup +export OPENSHELL_DIR=~/path/to/openshell # your OpenShell checkout +make dev-full # starts infra + dashboard +``` -# Point at your OpenShell source checkout -export OPENSHELL_DIR=~/path/to/openshell +That's it. `dev-full` starts Keycloak and the gateway (if not already running), writes a `scripts/.env.dev` config file, and launches the dashboard. On subsequent runs, `make dev` picks up the config automatically (no env vars needed). -# Start the infrastructure (Keycloak + gateway) -./scripts/dev-env.sh start - -# Run the dashboard with the printed env vars -export OPENSHELL_GATEWAY_URL=grpcs://localhost:17670 -export OIDC_ISSUER=http://localhost:8180/realms/openshell -export OIDC_CLIENT_ID=openshell-dashboard -export GATEWAY_CA_CERT=$(pwd)/scripts/.pki/ca.crt -export AUTH_DISABLED=false -make dev -``` +If `OPENSHELL_DIR` is not set, the script prompts interactively and offers to clone the repo for you. The chosen path is saved to `scripts/.env.dev` so you only configure it once. Open http://localhost:3000 and log in via Keycloak with one of the test users: @@ -60,7 +52,22 @@ Open http://localhost:3000 and log in via Keycloak with one of the test users: | `user@test` | `user` | Workspace member | | `user-b@test` | `user-b` | Workspace member | -The script is idempotent. Run `./scripts/dev-env.sh status` to check components, `stop` to tear down, or `rebuild-gateway` after pulling upstream changes. +### What `dev-full` starts + +| Component | How | Lifecycle | +|-----------|-----|-----------| +| Keycloak | Podman container (`openshell-keycloak`) on port 8180 | Runs until `dev-env.sh stop` | +| OpenShell gateway | Background process built from source, port 17670 (gRPCs) + 17671 (health) | Runs until `dev-env.sh stop` | +| Dashboard BFF | `go run` on port 8080 | Runs with `make dev`, Ctrl+C to stop | +| Dashboard frontend | Webpack dev server on port 3000 | Runs with `make dev`, Ctrl+C to stop | + +Keycloak and the gateway survive across `make dev` restarts. Stop them explicitly: + +```bash +./scripts/dev-env.sh stop # stops gateway + keycloak, cleans up orphans +./scripts/dev-env.sh status # check what's running +./scripts/dev-env.sh rebuild-gateway # rebuild after upstream changes +``` ## Configuration diff --git a/scripts/dev-env.sh b/scripts/dev-env.sh index 9508961..b45a8de 100755 --- a/scripts/dev-env.sh +++ b/scripts/dev-env.sh @@ -5,15 +5,65 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" PKI_DIR="$SCRIPT_DIR/.pki" STATE_DIR="$SCRIPT_DIR/.state" -if [ -z "${OPENSHELL_DIR:-}" ]; then - echo "ERROR: OPENSHELL_DIR is not set." >&2 - echo "Set it to the path of your OpenShell source checkout:" >&2 - echo " export OPENSHELL_DIR=~/path/to/openshell" >&2 - exit 1 -fi -OPENSHELL_DIR="${OPENSHELL_DIR}" +ENV_FILE="$SCRIPT_DIR/.env.dev" + +resolve_openshell_dir() { + if [ -n "${OPENSHELL_DIR:-}" ]; then + return 0 + fi + + if [ -f "$ENV_FILE" ]; then + local saved + saved=$(grep '^OPENSHELL_DIR=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-) + if [ -n "$saved" ] && [ -d "$saved" ]; then + OPENSHELL_DIR="$saved" + return 0 + fi + fi + + echo "" + echo "OpenShell source directory not configured." + echo "" + echo "Where is your OpenShell checkout?" + echo "" + echo " 1) Enter a path" + echo " 2) Clone from GitHub into ./openshell (next to this project)" + echo "" + printf "Choice [1/2]: " + read -r choice + + case "$choice" in + 2) + local clone_dir="$PROJECT_DIR/../openshell" + if [ -d "$clone_dir" ] && [ -f "$clone_dir/Cargo.toml" ]; then + echo "Found existing checkout at $clone_dir" + OPENSHELL_DIR="$(cd "$clone_dir" && pwd)" + else + echo "Cloning NVIDIA/OpenShell..." + git clone https://github.com/NVIDIA/OpenShell.git "$clone_dir" 2>&1 + OPENSHELL_DIR="$(cd "$clone_dir" && pwd)" + fi + ;; + *) + printf "Path to OpenShell source: " + read -r user_path + user_path="${user_path/#\~/$HOME}" + if [ ! -d "$user_path" ] || [ ! -f "$user_path/Cargo.toml" ]; then + error "Not a valid OpenShell checkout: $user_path" + exit 1 + fi + OPENSHELL_DIR="$(cd "$user_path" && pwd)" + ;; + esac + + echo "OPENSHELL_DIR=$OPENSHELL_DIR" >> "$ENV_FILE" 2>/dev/null || true + export OPENSHELL_DIR +} + +OPENSHELL_DIR="${OPENSHELL_DIR:-}" KEYCLOAK_PORT="${KEYCLOAK_PORT:-8180}" KEYCLOAK_CONTAINER="openshell-keycloak" +PODMAN_NETWORK="openshell-dev" GATEWAY_GRPC_PORT=17670 GATEWAY_HTTP_PORT=17671 GATEWAY_PID_FILE="$STATE_DIR/gateway.pid" @@ -378,11 +428,21 @@ build_gateway() { info "openshell-gateway built" } +ensure_podman_network() { + if podman network inspect "$PODMAN_NETWORK" >/dev/null 2>&1; then + return 0 + fi + podman network create --driver bridge "$PODMAN_NETWORK" >/dev/null 2>&1 + info "Podman network '$PODMAN_NETWORK' created" +} + generate_gateway_config() { mkdir -p "$STATE_DIR" local podman_socket podman_socket=$(detect_podman_socket) + ensure_podman_network + cat > "$GATEWAY_CONFIG_FILE" </dev/null; then local result + local proto_import="$OPENSHELL_DIR/proto" result=$(grpcurl -H "Authorization: Bearer $admin_token" \ -cacert "$PKI_DIR/ca.crt" \ + -import-path "$proto_import" -proto openshell.proto \ -d '{"name": "default"}' \ "localhost:${GATEWAY_GRPC_PORT}" \ openshell.v1.OpenShell/CreateWorkspace 2>&1 || true) @@ -517,19 +580,36 @@ create_default_workspace() { fi } +write_env_file() { + cat > "$ENV_FILE" </dev/null || true + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + local waited=0 + while kill -0 "$pid" 2>/dev/null && [ "$waited" -lt 10 ]; do + sleep 1 + waited=$((waited + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + warn "Gateway killed forcefully (PID $pid)" + else + info "Gateway stopped (PID $pid)" + fi + else + info "Gateway PID $pid already gone (stale PID file)" + fi rm -f "$GATEWAY_PID_FILE" - info "Gateway stopped (PID $pid)" - else - info "Gateway not running" fi - if keycloak_is_running; then - podman stop "$KEYCLOAK_CONTAINER" >/dev/null 2>&1 || true - podman rm "$KEYCLOAK_CONTAINER" >/dev/null 2>&1 || true + # Check for orphaned gateway processes on the expected port + local orphan_pid + orphan_pid=$(lsof -ti :"$GATEWAY_GRPC_PORT" 2>/dev/null || true) + if [ -n "$orphan_pid" ]; then + kill "$orphan_pid" 2>/dev/null || true + warn "Killed orphaned process on port $GATEWAY_GRPC_PORT (PID $orphan_pid)" + fi + + # Keycloak: force remove regardless of state (handles stopped, running, or broken containers) + if podman container exists "$KEYCLOAK_CONTAINER" 2>/dev/null; then + podman stop "$KEYCLOAK_CONTAINER" 2>/dev/null || true + podman rm -f "$KEYCLOAK_CONTAINER" 2>/dev/null || true info "Keycloak stopped and removed" else - podman rm -f "$KEYCLOAK_CONTAINER" >/dev/null 2>&1 || true info "Keycloak not running" fi + # Clean up stale state files (preserve PKI, DB, and env config) + rm -f "$GATEWAY_PID_FILE" "$GATEWAY_LOG_FILE" "$GATEWAY_CONFIG_FILE" + info "State files cleaned" + echo "" } @@ -608,6 +713,7 @@ cmd_status() { } cmd_rebuild_gateway() { + resolve_openshell_dir step "Rebuilding gateway" if gateway_is_running; then From 49c9aaa7d482d12a8ca3b26c7ce7861befb6550c Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Fri, 31 Jul 2026 17:36:43 -0500 Subject: [PATCH 2/3] Redesign sandbox cards, table, and attention alerts (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Redesign sandbox cards, table, and attention alerts Card view: status dot + name + uptime header, PF Alert-based attention section with paginated prev/next for multiple items, DescriptionList for providers/labels, policy egress summary with expandable rule list showing rule name + host/binary counts + enforcement labels. Table view: new columns β€” Name (with image subtitle), Status (dot + phase text), Policy (shield icon + version + host summary + proposed badge), Providers (teal labels with overflow). Actions kebab has Terminal, Logs, Delete. Attention system: shared SandboxAttention component with two modes β€” card (single alert with top-right 1/N pager) and detail (AlertGroup stack with dismiss). Builds severity-ranked items from sandbox phase, policy revision status, and draft proposal summary. Shared utilities: StatusDot component, getStatusDotColor, countEgressHosts, getEnforcementLabel/Color extracted to utils.ts. Policy data sourced from policy view API (not stale spec.policy). Draft summary endpoint re-added and wired to card view. Also: BFF draft-summary route fix (binary was stale), advisorProposed added to NetworkEndpoint type, PF6 consistency pass (DescriptionList, Content, Alert, Divider, CardTitle removal, no hardcoded pixels). Co-Authored-By: Claude Opus 4.6 (1M context) * Add Labels column to sandbox table LabelsList now accepts numLabels prop (default 3). Table shows labels with overflow at 2. Co-Authored-By: Claude Opus 4.6 (1M context) * Add Claude project settings with PatternFly plugins Enables patternfly-mcp and design-audit plugins for PF6 component docs, token checking, and design compliance. Co-Authored-By: Claude Opus 4.6 (1M context) * Address PR review findings - Replace 'rhoai' with 'managed' in DeploymentContext type - Feature-gate Terminal button and table action behind features.terminal - Gate draft-summary polling on features.draftPolicy (enabled param) - Gate onReviewDrafts callback on features.draftPolicy - Extract SandboxEgressSummary component (97 lines out of card) - Share getPolicySummary between card and table (remove duplicate) - Export SandboxAttention, SandboxEgressSummary, StatusDot from barrel - Replace hardcoded logo height with PF spacer token - Add useSlots to SandboxCard for sandboxActions/sandboxMetadata slots - Move attention pager out of Alert actionClose into sibling flex Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .claude/settings.json | 6 + backend/cmd/server/main.go | 3 + backend/internal/api/gateway_handler.go | 17 +- backend/internal/api/policies_handler.go | 1 + frontend/package.json | 3 +- frontend/public/favicon.svg | 7 + frontend/public/index.html | 1 + frontend/src/api/auth.ts | 3 + frontend/src/api/index.ts | 1 + frontend/src/api/policy.ts | 45 ++- frontend/src/api/rbac.ts | 36 ++ frontend/src/app/App.tsx | 13 +- frontend/src/app/AppLayout.tsx | 9 +- frontend/src/app/useUserRole.ts | 16 +- frontend/src/app/useWorkspaceRole.ts | 24 +- frontend/src/assets/openshell-logo.svg | 1 + .../src/components/CreateProviderModal.tsx | 7 +- frontend/src/components/InferenceTab.tsx | 7 +- frontend/src/components/LabelsList.tsx | 5 +- frontend/src/components/SandboxAttention.tsx | 229 +++++++++++++ frontend/src/components/SandboxCard.tsx | 268 +++++++++++++++ .../src/components/SandboxEgressSummary.tsx | 164 +++++++++ .../src/components/SandboxGalleryView.tsx | 83 +++++ frontend/src/components/StatusDot.tsx | 22 ++ frontend/src/components/index.ts | 6 + frontend/src/components/utils.ts | 61 ++++ frontend/src/pages/ProviderListPage.tsx | 5 +- frontend/src/pages/SandboxDetailPage.tsx | 29 +- frontend/src/pages/SandboxListPage.tsx | 317 ++++++++++++++---- frontend/src/pages/WorkspaceDetailPage.tsx | 8 +- frontend/src/slots/SlotContext.tsx | 21 ++ frontend/src/slots/index.ts | 2 + frontend/src/types/assets.d.ts | 14 + frontend/src/types/index.ts | 19 ++ 34 files changed, 1315 insertions(+), 138 deletions(-) create mode 100644 .claude/settings.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/api/rbac.ts create mode 100644 frontend/src/assets/openshell-logo.svg create mode 100644 frontend/src/components/SandboxAttention.tsx create mode 100644 frontend/src/components/SandboxCard.tsx create mode 100644 frontend/src/components/SandboxEgressSummary.tsx create mode 100644 frontend/src/components/SandboxGalleryView.tsx create mode 100644 frontend/src/components/StatusDot.tsx create mode 100644 frontend/src/slots/SlotContext.tsx create mode 100644 frontend/src/slots/index.ts create mode 100644 frontend/src/types/assets.d.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3d17061 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "enabledPlugins": { + "design-audit@patternfly-ai-helpers": true, + "patternfly-mcp@patternfly-ai-helpers": true + } +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c723f6a..66d010c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -68,6 +68,9 @@ func main() { CredentialRefresh: envOr("FEATURE_CREDENTIAL_REFRESH", "true") == "true", Services: envOr("FEATURE_SERVICES", "true") == "true", DraftPolicy: envOr("FEATURE_DRAFT_POLICY", "true") == "true", + DeploymentContext: envOr("DEPLOYMENT_CONTEXT", "standalone"), + WorkspaceBinding: envOr("FEATURE_WORKSPACE_BINDING", "false") == "true", + ResourceLinks: envOr("FEATURE_RESOURCE_LINKS", "false") == "true", }, }) diff --git a/backend/internal/api/gateway_handler.go b/backend/internal/api/gateway_handler.go index 9e51560..a4b1bba 100644 --- a/backend/internal/api/gateway_handler.go +++ b/backend/internal/api/gateway_handler.go @@ -26,13 +26,16 @@ func (app *App) GetGateway(w http.ResponseWriter, r *http.Request) { // FeatureFlags controls which optional features the frontend should render. // Parsed from FEATURE_* env vars in main.go. type FeatureFlags struct { - Terminal bool `json:"terminal"` - FileTransfer bool `json:"fileTransfer"` - Settings bool `json:"settings"` - GlobalPolicy bool `json:"globalPolicy"` - CredentialRefresh bool `json:"credentialRefresh"` - Services bool `json:"services"` - DraftPolicy bool `json:"draftPolicy"` + Terminal bool `json:"terminal"` + FileTransfer bool `json:"fileTransfer"` + Settings bool `json:"settings"` + GlobalPolicy bool `json:"globalPolicy"` + CredentialRefresh bool `json:"credentialRefresh"` + Services bool `json:"services"` + DraftPolicy bool `json:"draftPolicy"` + DeploymentContext string `json:"deploymentContext"` + WorkspaceBinding bool `json:"workspaceBinding"` + ResourceLinks bool `json:"resourceLinks"` } // AuthConfigResponse tells the frontend how to authenticate and which diff --git a/backend/internal/api/policies_handler.go b/backend/internal/api/policies_handler.go index 67da963..7f73ff9 100644 --- a/backend/internal/api/policies_handler.go +++ b/backend/internal/api/policies_handler.go @@ -262,3 +262,4 @@ func (app *App) GetDraftHistory(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, models.FromDraftHistory(resp)) } + diff --git a/frontend/package.json b/frontend/package.json index 2e3b783..95f1c81 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "./pages": "./src/pages/index.ts", "./components": "./src/components/index.ts", "./api": "./src/api/index.ts", - "./types": "./src/types/index.ts" + "./types": "./src/types/index.ts", + "./slots": "./src/slots/index.ts" }, "scripts": { "start": "webpack serve --mode development", diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6ba4fee --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/index.html b/frontend/public/index.html index b26b301..2ddbbce 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -3,6 +3,7 @@ + OpenShell Dashboard diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 518d307..7592da4 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -43,6 +43,9 @@ export const useFeatureFlags = () => { credentialRefresh: true, services: true, draftPolicy: true, + deploymentContext: 'standalone', + workspaceBinding: false, + resourceLinks: false, }; return data?.features ?? defaults; }; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 57b13d7..6913454 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -8,3 +8,4 @@ export * from './sandboxes'; export * from './providers'; export * from './policy'; export * from './inference'; +export * from './rbac'; diff --git a/frontend/src/api/policy.ts b/frontend/src/api/policy.ts index ddeb70c..09be243 100644 --- a/frontend/src/api/policy.ts +++ b/frontend/src/api/policy.ts @@ -1,9 +1,11 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiFetch, del, get, post, put } from './client'; import type { DraftHistoryEntry, DraftPolicy, + DraftSummary, NetworkPolicyRule, PolicyUpdateResult, SandboxPolicy, @@ -82,6 +84,26 @@ export const useSandboxPolicy = (workspace: string, name: string) => queryFn: () => getSandboxPolicy(workspace, name), }); +export const useSandboxPolicies = (workspace: string, names: string[]) => { + const queries = useQueries({ + queries: names.map((name) => ({ + queryKey: ['sandbox-policy', workspace, name], + queryFn: () => getSandboxPolicy(workspace, name), + })), + }); + + const dataFingerprint = queries.map((q) => q.dataUpdatedAt).join(','); + + return useMemo(() => { + const views: Record = {}; + queries.forEach((q, i) => { + if (q.data) views[names[i]] = q.data; + }); + return views; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataFingerprint]); +}; + export const useUpdateSandboxPolicy = (workspace: string, name: string) => { const queryClient = useQueryClient(); return useMutation({ @@ -214,3 +236,24 @@ export const useDraftHistory = (workspace: string, name: string) => queryKey: ['draft-history', workspace, name], queryFn: () => getDraftHistory(workspace, name), }); + +const getDraftSummary = (workspace?: string): Promise => + get( + `/api/v1/draft-summary${workspace ? `?workspace=${encodeURIComponent(workspace)}` : ''}`, + ); + +export const useDraftNotifications = (enabled = true) => { + const query = useQuery({ + queryKey: ['draft-summary'], + queryFn: () => getDraftSummary(), + refetchInterval: 15_000, + enabled, + }); + + return { + items: query.data?.sandboxes ?? [], + totalPending: query.data?.totalPending ?? 0, + isLoading: query.isLoading, + }; +}; + diff --git a/frontend/src/api/rbac.ts b/frontend/src/api/rbac.ts new file mode 100644 index 0000000..772700c --- /dev/null +++ b/frontend/src/api/rbac.ts @@ -0,0 +1,36 @@ +import { useCurrentUser } from './auth'; +import { useMembers } from './workspaces'; + +export const PLATFORM_ADMIN_ROLE = 'openshell-admin'; +export const USER_ROLE = 'openshell-user'; + +export const useUserRole = () => { + const { data: user } = useCurrentUser(); + const roles = user?.roles ?? []; + return { + isPlatformAdmin: roles.includes(PLATFORM_ADMIN_ROLE), + isUser: roles.includes(USER_ROLE) || roles.includes(PLATFORM_ADMIN_ROLE), + roles, + subject: user?.subject, + }; +}; + +export const useWorkspaceRole = (workspace: string) => { + const { data: user } = useCurrentUser(); + const members = useMembers(workspace); + + const isPlatformAdmin = (user?.roles ?? []).includes(PLATFORM_ADMIN_ROLE); + + if (isPlatformAdmin) { + return { isWorkspaceAdmin: true, isLoading: false }; + } + + const currentMember = (members.data ?? []).find( + (m) => m.principalSubject === user?.subject, + ); + + return { + isWorkspaceAdmin: currentMember?.role === 'ADMIN', + isLoading: members.isLoading, + }; +}; diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 7cf7db6..b4c90c4 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -11,6 +11,7 @@ import { useParams, } from 'react-router-dom'; +import { SlotProvider } from '../slots'; import LoginPage from '../pages/LoginPage'; import GatewayOverviewPage from '../pages/GatewayOverviewPage'; import WorkspaceListPage from '../pages/WorkspaceListPage'; @@ -155,11 +156,13 @@ const AppRoutes: React.FC = () => { const App: React.FC = () => ( - - - - - + + + + + + + ); diff --git a/frontend/src/app/AppLayout.tsx b/frontend/src/app/AppLayout.tsx index 9c271ec..6620bc8 100644 --- a/frontend/src/app/AppLayout.tsx +++ b/frontend/src/app/AppLayout.tsx @@ -33,6 +33,7 @@ import { import { BarsIcon, QuestionCircleIcon } from '@patternfly/react-icons'; import { Link, useLocation } from 'react-router-dom'; +import openshellLogo from '~/assets/openshell-logo.svg'; import { useGatewayInfo } from '../api/gateway'; import { useCurrentUser, useFeatureFlags } from '../api/auth'; import { useUserRole } from './useUserRole'; @@ -80,9 +81,7 @@ const AppLayout: React.FC = ({ children }) => { )} > - - OpenShell Dashboard - + OpenShell Dashboard @@ -209,8 +208,8 @@ const AppLayout: React.FC = ({ children }) => { onClose={() => setAboutOpen(false)} productName="OpenShell Dashboard" trademark="Apache-2.0 license." - brandImageSrc="" - brandImageAlt="" + brandImageSrc={openshellLogo} + brandImageAlt="OpenShell Dashboard" > diff --git a/frontend/src/app/useUserRole.ts b/frontend/src/app/useUserRole.ts index b59e966..c88ca70 100644 --- a/frontend/src/app/useUserRole.ts +++ b/frontend/src/app/useUserRole.ts @@ -1,15 +1 @@ -import { useCurrentUser } from '../api/auth'; - -export const PLATFORM_ADMIN_ROLE = 'openshell-admin'; -export const USER_ROLE = 'openshell-user'; - -export const useUserRole = () => { - const { data: user } = useCurrentUser(); - const roles = user?.roles ?? []; - return { - isPlatformAdmin: roles.includes(PLATFORM_ADMIN_ROLE), - isUser: roles.includes(USER_ROLE) || roles.includes(PLATFORM_ADMIN_ROLE), - roles, - subject: user?.subject, - }; -}; +export { PLATFORM_ADMIN_ROLE, USER_ROLE, useUserRole } from '../api/rbac'; diff --git a/frontend/src/app/useWorkspaceRole.ts b/frontend/src/app/useWorkspaceRole.ts index d508d96..1d5c3cb 100644 --- a/frontend/src/app/useWorkspaceRole.ts +++ b/frontend/src/app/useWorkspaceRole.ts @@ -1,23 +1 @@ -import { useCurrentUser } from '../api/auth'; -import { useMembers } from '../api/workspaces'; -import { PLATFORM_ADMIN_ROLE } from './useUserRole'; - -export const useWorkspaceRole = (workspace: string) => { - const { data: user } = useCurrentUser(); - const members = useMembers(workspace); - - const isPlatformAdmin = (user?.roles ?? []).includes(PLATFORM_ADMIN_ROLE); - - if (isPlatformAdmin) { - return { isWorkspaceAdmin: true, isLoading: false }; - } - - const currentMember = (members.data ?? []).find( - (m) => m.principalSubject === user?.subject, - ); - - return { - isWorkspaceAdmin: currentMember?.role === 'ADMIN', - isLoading: members.isLoading, - }; -}; +export { useWorkspaceRole } from '../api/rbac'; diff --git a/frontend/src/assets/openshell-logo.svg b/frontend/src/assets/openshell-logo.svg new file mode 100644 index 0000000..daec178 --- /dev/null +++ b/frontend/src/assets/openshell-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/CreateProviderModal.tsx b/frontend/src/components/CreateProviderModal.tsx index 7657423..4bfd0d4 100644 --- a/frontend/src/components/CreateProviderModal.tsx +++ b/frontend/src/components/CreateProviderModal.tsx @@ -23,6 +23,7 @@ import { import { useCreateProvider, useProviderProfiles } from '../api/providers'; import { useAlerts } from '../app/AlertContext'; +import { useSlots } from '../slots'; import type { CredentialInputSlot } from '../types'; type CreateProviderModalProps = { @@ -38,6 +39,8 @@ type CreateProviderModalProps = { // generated from the selected profile's credentials[] schema. Credential // values are write-only: sent to the gateway, never displayed again. const CreateProviderModal: React.FC = ({ workspace, isOpen, onClose, onSuccess, renderCredentialInput }) => { + const slots = useSlots(); + const resolvedCredentialInput = renderCredentialInput ?? slots.credentialInput; const [name, setName] = useState(''); const [profileId, setProfileId] = useState(''); const [credentialValues, setCredentialValues] = useState>({}); @@ -150,8 +153,8 @@ const CreateProviderModal: React.FC = ({ workspace, is isRequired={credential.required} fieldId={`credential-${credential.name}`} > - {renderCredentialInput ? ( - renderCredentialInput( + {resolvedCredentialInput ? ( + resolvedCredentialInput( credential, credentialValues[credential.name] ?? '', (value) => diff --git a/frontend/src/components/InferenceTab.tsx b/frontend/src/components/InferenceTab.tsx index 73cecac..f330c51 100644 --- a/frontend/src/components/InferenceTab.tsx +++ b/frontend/src/components/InferenceTab.tsx @@ -28,6 +28,7 @@ import { import { useDeleteInferenceRoute, useInferenceRoute, useSetInferenceRoute } from '../api/inference'; import { useProviders } from '../api/providers'; import { useWorkspaceRole } from '../app/useWorkspaceRole'; +import { useSlots } from '../slots'; import type { ApiError } from '../api/client'; import type { ModelPickerSlot } from '../types'; @@ -111,6 +112,8 @@ const RouteCard: React.FC<{ workspace: string; route: string; title: string; not // Inference routing: all sandboxes in the workspace reach inference.local, // and the gateway routes it to the configured provider/model. const InferenceTab: React.FC = ({ workspace, renderModelPicker }) => { + const slots = useSlots(); + const resolvedModelPicker = renderModelPicker ?? slots.modelPicker; const { isWorkspaceAdmin } = useWorkspaceRole(workspace); const providers = useProviders(workspace); const setRoute = useSetInferenceRoute(workspace); @@ -188,8 +191,8 @@ const InferenceTab: React.FC = ({ workspace, renderModelPicke - {renderModelPicker ? ( - renderModelPicker(modelId, setModelId) + {resolvedModelPicker ? ( + resolvedModelPicker(modelId, setModelId) ) : ( ; + numLabels?: number; }; -const LabelsList: React.FC = ({ labels }) => { +const LabelsList: React.FC = ({ labels, numLabels = 3 }) => { const entries = Object.entries(labels ?? {}); if (entries.length === 0) { return -; } return ( - + {entries.map(([key, value]) => (