CueWeb Development Guide

Complete guide for developing, customizing, and deploying CueWeb.

Table of contents
  1. Development Environment Setup
    1. Prerequisites
    2. Clone and Setup
    3. Development Configuration
    4. Start Development Server
  2. Project Structure
    1. Directory Layout
    2. Key Components
      1. Core Components
      2. UI Components
        1. Subscription store
  3. Architecture Overview
    1. Technology Stack
    2. Data Flow
    3. Authentication Flow
  4. Development Workflow
    1. Running in Development Mode
    2. Code Quality Tools
    3. Testing
    4. Building for Production
  5. API Integration
    1. OpenCue REST Gateway
      1. API Client Setup
      2. JWT Token Generation
    2. Job Comments
      1. Proxy routes
      2. Helpers
      3. Predefined comment macros
      4. Markdown rendering
      5. Viewer identity and authorization
      6. Comment indicator on the jobs table
    3. Data Fetching Patterns
      1. Server-Side Rendering (SSR)
      2. Client-Side Fetching
      3. Error Handling
  6. Component Development
    1. Creating New Components
      1. Component Structure
      2. Component Testing
    2. State Management
      1. React Context for Global State
      2. Custom Hooks
  7. Styling and Theming
    1. Tailwind CSS Configuration
    2. Theme Implementation
    3. Component Styling Patterns
  8. Configuration and Deployment
    1. Environment Configuration
      1. Development Environment
      2. Production Environment
    2. Docker Deployment
      1. Dockerfile
      2. Docker Compose
    3. Kubernetes Deployment

Development Environment Setup

Prerequisites

Before starting development, ensure you have:

  • Node.js (version 18 or later)
  • npm or yarn package manager
  • Git for version control
  • Docker (for REST Gateway and testing)
  • OpenCue running instance (Cuebot, RQD, PostgreSQL)

Clone and Setup

# Clone OpenCue repository
git clone https://github.com/AcademySoftwareFoundation/OpenCue.git
cd OpenCue/cueweb

# Install dependencies
npm install

# Create development environment file
cp .env.example .env

Development Configuration

Configure your .env file for development:

# .env file for development
NEXT_PUBLIC_OPENCUE_ENDPOINT=http://localhost:8448
NEXT_PUBLIC_URL=http://localhost:3000
NEXT_JWT_SECRET=dev-secret-key

# Development settings
NODE_ENV=development
NEXT_TELEMETRY_DISABLED=1

# Authentication (optional for development)
# NEXT_PUBLIC_AUTH_PROVIDER=github,google
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=dev-nextauth-secret

# Sentry (disabled for development)
# SENTRY_DSN=your-sentry-dsn
SENTRY_ENVIRONMENT=development

Start Development Server

# Start the development server
npm run dev

# Server will start at http://localhost:3000
# Hot reload enabled for development

Project Structure

Directory Layout

cueweb/
├── app/                  # Next.js App Router pages
│   ├── globals.css       # Global styles
│   ├── layout.tsx        # Root layout component
│   ├── page.tsx          # Home page
│   ├── login/            # Authentication pages
│   └── api/              # API routes
├── components/           # Reusable React components
│   ├── ui/               # Base UI components
│   ├── tables/           # Data table components
│   ├── dialogs/          # Modal dialogs
│   └── forms/            # Form components
├── lib/                  # Utility libraries
│   ├── auth.ts           # Authentication configuration
│   ├── api.ts            # API client functions
│   ├── utils.ts          # General utilities
│   └── types.ts          # TypeScript type definitions
├── public/               # Static assets
│   ├── icons/            # Application icons
│   └── images/           # Images and graphics
├── styles/               # Additional stylesheets
├── __tests__/            # Unit and integration tests
├── jest.config.js        # Jest testing configuration
├── next.config.js        # Next.js configuration
├── tailwind.config.js    # Tailwind CSS configuration
├── tsconfig.json         # TypeScript configuration
└── package.json          # Dependencies and scripts

Key Components

Core Components

  • JobsTable: Main jobs dashboard table
  • JobDetails: Job detail panel with layers/frames
  • FrameViewer: Frame log viewer component
  • SearchBar: Job search and filtering
  • ThemeProvider: Dark/light theme management
  • JobSubscriptionPoller: App-wide client provider (mounted in app/layout.tsx) that polls subscribed jobs every 15s. When a job reaches FINISHED it fires a browser notification via the Web Notifications API and marks the entry as notified. An inFlight ref guards against overlapping ticks, and jobs that no longer exist in Cuebot are removed from the store on the next poll.

UI Components

  • DataTable: Reusable table component with sorting/filtering
  • Button: Standardized button component
  • Dialog: Modal dialog wrapper
  • Select: Dropdown selection component
  • Toast: Notification system
  • SubscribeBell: Per-row bell button in the JobsTable Notify column. Reads/writes per-job subscription state via the useJobSubscriptions hook (app/utils/use_job_subscriptions.ts), backed by localStorage through app/utils/subscription_utils.ts. Every subscribe attempt calls requestNotificationPermission(); the browser only displays its native permission prompt when the current state is default (undecided) and resolves silently with the existing decision otherwise. If the resolved permission is not granted, the component surfaces a toast warning and skips the subscription. The button is disabled on rows whose jobState is already FINISHED and the row has no existing subscription.
Subscription store

Subscriptions are stored as a Record<jobId, JobSubscription> under the localStorage key cueweb:job-subscriptions. Each entry tracks jobId, jobName, subscribedAt, and notifiedAt (null until the poller fires the notification). Mutations dispatch a cueweb:subscriptions-changed window event so every useJobSubscriptions consumer re-reads from localStorage — this keeps the bell, the poller, and any other consumer in sync within the same tab without prop drilling. The store getter defensively returns {} for missing or malformed JSON so a stale or hand-edited entry cannot crash the UI.


Architecture Overview

Technology Stack

  • Framework: Next.js 14 (React 18)
  • Styling: Tailwind CSS + Radix UI
  • State Management: React hooks + Context
  • Authentication: NextAuth.js
  • API Client: Custom fetch wrapper
  • Type Safety: TypeScript
  • Testing: Jest + React Testing Library
  • Bundling: Next.js built-in (Webpack)

Data Flow

graph TD A[User Interaction] --> B[React Component] B --> C[API Client] C --> D[REST Gateway] D --> E[OpenCue Cuebot] E --> D D --> C C --> F[State Update] F --> G[UI Re-render]

Authentication Flow

sequenceDiagram participant User participant CueWeb participant NextAuth participant OAuth participant API User->>CueWeb: Access protected page CueWeb->>NextAuth: Check auth status NextAuth->>OAuth: Redirect for login OAuth->>NextAuth: Return auth token NextAuth->>CueWeb: Set session CueWeb->>API: Generate JWT token API->>CueWeb: Return API access token CueWeb->>User: Show authenticated UI

Development Workflow

Running in Development Mode

# Start development server with hot reload
npm run dev

# Run with specific port
npm run dev -- -p 3001

# Run with debug mode
DEBUG=* npm run dev

Code Quality Tools

# Run ESLint
npm run lint

# Fix linting issues automatically
npm run lint -- --fix

# Format code with Prettier
npm run format:fix

# Check formatting
npm run format:check

Testing

# Run all tests
npm test

# Run tests in watch mode
npm run test:watch

# Run tests with coverage
npm run coverage

# Run specific test file
npm test -- JobsTable.test.tsx

Building for Production

# Build production bundle
npm run build

# Start production server
npm run start

# Analyze bundle size
npm run build -- --analyze

API Integration

OpenCue REST Gateway

CueWeb communicates with OpenCue through the REST Gateway using JWT authentication.

API Client Setup

// lib/api.ts
import { createJWTToken } from './auth';

class OpenCueAPI {
  private baseUrl: string;
  private jwtSecret: string;

  constructor() {
    this.baseUrl = process.env.NEXT_PUBLIC_OPENCUE_ENDPOINT!;
    this.jwtSecret = process.env.NEXT_JWT_SECRET!;
  }

  private async getAuthHeaders() {
    const token = createJWTToken(this.jwtSecret, 'cueweb-user');
    return {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    };
  }

  async fetchShows() {
    const headers = await this.getAuthHeaders();
    const response = await fetch(
      `${this.baseUrl}/show.ShowInterface/GetShows`,
      {
        method: 'POST',
        headers,
        body: JSON.stringify({}),
      }
    );
    return response.json();
  }
}

JWT Token Generation

// lib/auth.ts
import jwt from 'jsonwebtoken';

export function createJWTToken(secret: string, userId: string): string {
  const payload = {
    sub: userId,
    exp: Math.floor(Date.now() / 1000) + (60 * 60), // 1 hour
  };

  return jwt.sign(payload, secret, { algorithm: 'HS256' });
}

Job Comments

CueWeb implements the CueGUI Comments dialog (cuegui/cuegui/Comments.py) via four proxy routes that wrap the underlying gRPC services.

Proxy routes

Browser route Forwards to Notes
POST /api/job/getcomments job.JobInterface/GetComments Returns the Comment array flattened from data.comments.comments.
POST /api/job/action/addcomment job.JobInterface/AddComment Body: { job: { id, name }, new_comment: { user, subject, message } }.
POST /api/comment/action/save comment.CommentInterface/Save Body: { comment: Comment }. comment.id is required.
POST /api/comment/action/delete comment.CommentInterface/Delete Body: { comment: Comment }. Only comment.id is read.

Helpers

Located in app/utils/ and consumed by the Comments page (app/jobs/[job-name]/comments/page.tsx):

// app/utils/get_utils.ts
export type JobComment = {
  id: string;
  timestamp: number;  // unix seconds — mirrors comment.Comment in proto/src/comment.proto
  user: string;
  subject: string;
  message: string;
};
export async function getJobComments(job: Job): Promise<JobComment[]>;

// app/utils/action_utils.ts
export async function addJobComment(job: Job, username: string, subject: string, message: string): Promise<void>;
export async function saveJobComment(comment: JobComment): Promise<void>;
export async function deleteJobComment(comment: JobComment): Promise<void>;

Predefined comment macros

Macros are stored per-browser in localStorage under the cueweb-comment-macros key. Loading, upserting (with optional rename), and deleting are exposed by app/utils/comment_macros.ts:

export type CommentMacro = { name: string; subject: string; message: string };
export function loadCommentMacros(): CommentMacro[];
export function upsertCommentMacro(macro: CommentMacro, replaceName?: string): CommentMacro[];
export function deleteCommentMacro(name: string): CommentMacro[];

Markdown rendering

Comment messages are rendered with react-markdown and sanitized with rehype-sanitize — embedded HTML/scripts are stripped before render.

Viewer identity and authorization

The Comments page derives the signed-in user from the authenticated NextAuth session by fetching /api/auth/session on mount, applying the same email → name precedence used in app/page.tsx. URL query parameters are never used as an authorization signal.

The session-derived currentUser only drives client-side UI state:

  • isAuthor = comment.user === currentUser enables/disables the editor and Delete button.
  • addJobComment(..., currentUser, ...) stamps new-comment author from the session, not the URL.

Authoritative ownership enforcement lives server-side in Cuebot. The client-side gate is a convenience to avoid a doomed round-trip; Cuebot still rejects unauthorized save/delete attempts.

Comment indicator on the jobs table

The Job columns definition (app/jobs/columns.tsx) renders a StickyNote (lucide-react) icon next to the show-shot-user line when Job.hasComment is true. The cell reads username from table.options.meta and forwards it as a query hint when opening the Comments page; the Comments page does not use it for authorization, but the hint keeps the new tab self-describing.

Both the indicator click and the context-menu “Comments” entry open the page with window.open(url, "_blank", "noopener,noreferrer") so the new tab cannot reach back via window.opener and the Referer header is suppressed.

Data Fetching Patterns

Server-Side Rendering (SSR)

// app/page.tsx
import { getShows } from '@/lib/api';

export default async function HomePage() {
  const shows = await getShows();

  return (
    <div>
      <JobsTable initialShows={shows} />
    </div>
  );
}

Client-Side Fetching

// components/JobsTable.tsx
import { useEffect, useState } from 'react';
import { useAPI } from '@/lib/hooks/useAPI';

export function JobsTable() {
  const { data: jobs, loading, error, refetch } = useAPI('/jobs');

  useEffect(() => {
    const interval = setInterval(refetch, 30000); // Auto-refresh
    return () => clearInterval(interval);
  }, [refetch]);

  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;

  return <DataTable data={jobs} />;
}

Error Handling

// lib/api.ts
export class APIError extends Error {
  constructor(
    public status: number,
    public message: string,
    public code?: string
  ) {
    super(message);
    this.name = 'APIError';
  }
}

async function handleResponse(response: Response) {
  if (!response.ok) {
    const error = await response.json();
    throw new APIError(
      response.status,
      error.message || 'API request failed',
      error.code
    );
  }
  return response.json();
}

Component Development

Creating New Components

Component Structure

// components/JobCard.tsx
import React from 'react';
import { Job } from '@/lib/types';

interface JobCardProps {
  job: Job;
  onPause: (jobId: string) => void;
  onKill: (jobId: string) => void;
  className?: string;
}

export function JobCard({ job, onPause, onKill, className }: JobCardProps) {
  return (
    <div className={`job-card ${className}`}>
      <h3>{job.name}</h3>
      <p>Status: {job.status}</p>
      <div className="actions">
        <button onClick={() => onPause(job.id)}>
          {job.isPaused ? 'Resume' : 'Pause'}
        </button>
        <button onClick={() => onKill(job.id)}>Kill</button>
      </div>
    </div>
  );
}

Component Testing

// __tests__/JobCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { JobCard } from '@/components/JobCard';

const mockJob = {
  id: 'job-1',
  name: 'Test Job',
  status: 'RUNNING',
  isPaused: false,
};

describe('JobCard', () => {
  it('renders job information', () => {
    render(
      <JobCard
        job={mockJob}
        onPause={jest.fn()}
        onKill={jest.fn()}
      />
    );

    expect(screen.getByText('Test Job')).toBeInTheDocument();
    expect(screen.getByText('Status: RUNNING')).toBeInTheDocument();
  });

  it('calls onPause when pause button clicked', () => {
    const onPause = jest.fn();
    render(
      <JobCard
        job={mockJob}
        onPause={onPause}
        onKill={jest.fn()}
      />
    );

    fireEvent.click(screen.getByText('Pause'));
    expect(onPause).toHaveBeenCalledWith('job-1');
  });
});

State Management

React Context for Global State

// lib/context/JobsContext.tsx
import React, { createContext, useContext, useReducer } from 'react';

interface JobsState {
  jobs: Job[];
  selectedJobs: string[];
  filters: JobFilters;
}

type JobsAction =
  | { type: 'SET_JOBS'; payload: Job[] }
  | { type: 'UPDATE_JOB'; payload: Job }
  | { type: 'SELECT_JOB'; payload: string }
  | { type: 'SET_FILTERS'; payload: JobFilters };

const JobsContext = createContext<{
  state: JobsState;
  dispatch: React.Dispatch<JobsAction>;
} | null>(null);

export function JobsProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(jobsReducer, initialState);

  return (
    <JobsContext.Provider value=>
      {children}
    </JobsContext.Provider>
  );
}

export function useJobs() {
  const context = useContext(JobsContext);
  if (!context) {
    throw new Error('useJobs must be used within JobsProvider');
  }
  return context;
}

Custom Hooks

// lib/hooks/useJobActions.ts
import { useCallback } from 'react';
import { useAPI } from './useAPI';
import { useToast } from './useToast';

export function useJobActions() {
  const { toast } = useToast();

  const pauseJob = useCallback(async (jobId: string) => {
    try {
      await fetch('/api/jobs/pause', {
        method: 'POST',
        body: JSON.stringify({ jobId }),
      });
      toast.success('Job paused successfully');
    } catch (error) {
      toast.error('Failed to pause job');
    }
  }, [toast]);

  const killJob = useCallback(async (jobId: string) => {
    try {
      await fetch('/api/jobs/kill', {
        method: 'POST',
        body: JSON.stringify({ jobId }),
      });
      toast.success('Job killed successfully');
    } catch (error) {
      toast.error('Failed to kill job');
    }
  }, [toast]);

  return { pauseJob, killJob };
}

Styling and Theming

Tailwind CSS Configuration

// tailwind.config.js
module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        // Custom color palette
        primary: {
          50: '#eff6ff',
          500: '#3b82f6',
          900: '#1e3a8a',
        },
        // Status colors
        success: '#10b981',
        warning: '#f59e0b',
        error: '#ef4444',
        // Job status colors
        running: '#10b981',
        paused: '#6b7280',
        failed: '#ef4444',
        pending: '#f59e0b',
      },
      animation: {
        'fade-in': 'fadeIn 0.2s ease-in-out',
        'slide-up': 'slideUp 0.3s ease-out',
      },
    },
  },
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography'),
  ],
};

Theme Implementation

// components/ThemeProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react';

type Theme = 'light' | 'dark' | 'system';

const ThemeContext = createContext<{
  theme: Theme;
  setTheme: (theme: Theme) => void;
} | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('system');

  useEffect(() => {
    const root = window.document.documentElement;

    if (theme === 'dark' ||
        (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      root.classList.add('dark');
    } else {
      root.classList.remove('dark');
    }
  }, [theme]);

  return (
    <ThemeContext.Provider value=Jekyll::Drops::ThemeDrop>
      {children}
    </ThemeContext.Provider>
  );
}

Component Styling Patterns

// components/ui/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';

const buttonVariants = cva(
  // Base styles
  'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

export function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button
      className={buttonVariants({ variant, size, className })}
      {...props}
    />
  );
}

Configuration and Deployment

Environment Configuration

Development Environment

# .env.local (for local development overrides)
NEXT_PUBLIC_OPENCUE_ENDPOINT=http://localhost:8448
NEXT_PUBLIC_URL=http://localhost:3000
NEXT_JWT_SECRET=dev-secret-very-long-key

# Debug settings
DEBUG=cueweb:*
NODE_ENV=development
NEXT_TELEMETRY_DISABLED=1

# Development database (if using local DB)
DATABASE_URL=postgresql://user:pass@localhost:5432/opencue_dev

Production Environment

# .env.production
NEXT_PUBLIC_OPENCUE_ENDPOINT=https://api.renderfarm.company.com
NEXT_PUBLIC_URL=https://cueweb.company.com
NEXT_JWT_SECRET=production-secret-key-very-long-and-secure

# Production optimizations
NODE_ENV=production
NEXT_TELEMETRY_DISABLED=1

# Monitoring
SENTRY_DSN=https://your-sentry-dsn
SENTRY_ENVIRONMENT=production

# Authentication
NEXT_PUBLIC_AUTH_PROVIDER=okta,google
NEXTAUTH_URL=https://cueweb.company.com
NEXTAUTH_SECRET=nextauth-production-secret

# OAuth credentials (from secure storage)
OKTA_CLIENT_ID=${OKTA_CLIENT_ID}
OKTA_CLIENT_SECRET=${OKTA_CLIENT_SECRET}
OKTA_ISSUER=https://company.okta.com

Docker Deployment

Dockerfile

# cueweb/Dockerfile
FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Build the app
FROM base AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production image
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

Docker Compose

# docker-compose.yml
version: '3.8'

services:
  cueweb:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NEXT_PUBLIC_OPENCUE_ENDPOINT=http://rest-gateway:8448
      - NEXT_PUBLIC_URL=http://localhost:3000
      - NEXT_JWT_SECRET=${JWT_SECRET}
      - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
    depends_on:
      - rest-gateway
    networks:
      - opencue

  rest-gateway:
    image: opencue-rest-gateway:latest
    ports:
      - "8448:8448"
    environment:
      - CUEBOT_ENDPOINT=cuebot:8443
      - JWT_SECRET=${JWT_SECRET}
      - REST_PORT=8448
    networks:
      - opencue

networks:
  opencue:
    external: true

Kubernetes Deployment

# k8s/cueweb-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cueweb
  labels:
    app: cueweb
spec:
  replicas: 3
  selector:
    matchLabels:
      app: cueweb
  template:
    metadata:
      labels:
        app: cueweb
    spec:
      containers:
      - name: cueweb
        image: cueweb:latest
        ports:
        - containerPort: 3000
        env:
        - name: NEXT_PUBLIC_OPENCUE_ENDPOINT
          value: "http://rest-gateway:8448"
        - name: NEXT_PUBLIC_URL
          value: "https://cueweb.company.com"
        - name: NEXT_JWT_SECRET
          valueFrom:
            secretKeyRef:
              name: cueweb-secrets
              key: jwt-secret
        - name: NEXTAUTH_SECRET
          valueFrom:
            secretKeyRef:
              name: cueweb-secrets
              key: nextauth-secret
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /api/health
            port: 3000
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /api/health
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 5

---
apiVersion: v1
kind: Service
metadata:
  name: cueweb
spec:
  selector:
    app: cueweb
  ports:
  - port: 3000
    targetPort: 3000
  type: ClusterIP

Back to top