Compare commits

...

85 Commits

Author SHA1 Message Date
alex 22c035984e feat: add config profiles data model and migrations
- Add ConfigProfile model with user ownership, name, description
- Add ConfigInclude model for ordered profile self-references
- Add ConfigMount model for mount/file definitions
- Add selected_profile_id to ToolInstance for per-instance profile selection
- Add default profile properties to UserConfig JSONB config
- Create Alembic migration 0013 for new tables and columns
- Register new models in models/__init__.py
- Mark config_folders.is_active as deprecated
- Add migration metadata test

Quality gates: syntax check passed (all files parse successfully)
OpenSpec: add-config-profiles task 1.1
2026-05-24 12:54:45 +00:00
alex d35037df01 docs: add OpenSpec status review and update add-config-profiles tasks
- Create comprehensive OpenSpec status and implementation checklist review
- Document current state: 11 active changes, 66/345 tasks complete (19.1%)
- Update add-config-profiles/tasks.md to reflect completed model work
- Identify near-completion changes, blockers, and recommendations

Quality gates: review document only, no code changes
2026-05-24 12:54:23 +00:00
miguel 99097090e6 fix: resolve test failures after merge and add missing labels
- Fix tool-workshop test selectors to match component labels
- Fix API test mocks for axios client
- Add htmlFor attributes to form labels in tool-workshop
- Update type signatures to match API interfaces

Quality gates: 43/43 tests pass, typecheck pass, lint pass
2026-05-22 21:15:06 +02:00
miguel 6f35eb77ae feat: redesign authenticated UI with home overview and settings hub
- Add HomePage with open sessions grid, projects overview, and session composer
- Add SettingsPage with tabs for General, SSH Keys, Tool Types, Tool Configs
- Update navigation to Home, Projects, Settings
- Redirect legacy routes (/sessions, /ssh-keys, /tool-types, /tool-configs)
- Apply Inter font and warm editorial styling
- Update tests for new dashboard and projects pages

Quality gates: typecheck pass, lint pass, 15/15 tests pass

Refs: openspec/changes/ui-redesign-home-settings
2026-05-22 20:38:05 +02:00
Fusion 575e5837ea Merge branch 'main' of ssh://git.commumedia.org:2222/alex/headquarter 2026-05-22 20:34:20 +02:00
Fusion 996ea73bbf fix: resolve remaining integration test failures
- Add POST /tool-types/validate endpoint for pre-creation validation
- Add ToolConfigUpdate model with optional fields for PUT endpoint
- Fix tool_configs POST to return 201 status code
- Fix tool_configs list endpoint to return list instead of dict
- Fix tool_configs defaults endpoint to return 'suggested_configs'
- Fix tool_types create endpoint to include category and interfaces
- Add model_validator to enforce dockerfile/compose template requirements
- Update tests to match API response format
2026-05-22 20:33:29 +02:00
miguel d0e5feeaa5 test: fix mock to actually exercise orphan branch path 2026-05-22 20:29:30 +02:00
Fusion 1f784b552d fix: resolve config folders API bugs and test infrastructure
- Fix validation error handler to serialize ValueError objects safely
- Add GET /config-folders/{id} endpoint (was missing)
- Fix project overrides API to accept project_id in body instead of query param
- Add flag_modified for SQLAlchemy JSONB change detection
- Fix DELETE endpoint to return 204 status code
- Fix conftest.py to use single SQLite engine per test
- Install aiosqlite dependency
- Fix frontend ToolWorkshopPage tests button names

Config folders tests: 13/13 passing
Docker build tests: 10/10 passing
Readiness probe tests: 13/13 passing
2026-05-22 20:16:23 +02:00
miguel 10cbe63095 fix: handle unborn HEAD branch creation
- Create orphan branches when HEAD does not exist yet\n- Use current branch as the default branch base in the toolbar\n- Keep push disabled for remote-less repos\n\nQuality gates: vitest repositories-settings-tab (passed); api pytest blocked by missing fastapi in environment
2026-05-22 20:02:51 +02:00
miguel 4547105f3b fix: use working clones for git repos
- Create normal working clones for remote repositories
- Initialize blank repositories with a main branch
- Align pull and branch helpers with unborn HEAD handling
- Gate fetch/pull on repositories with a remote

Quality gates: vitest repositories-settings-tab (passed); api pytest blocked by missing fastapi in environment
2026-05-22 19:54:42 +02:00
Fusion dacf105200 test: add comprehensive tests for tool workshop functionality
Backend tests:
- Unit tests for docker_build service (successful/failed builds, context, paths)
- Unit tests for readiness_probe service (success, timeout, retries, edge cases)
- Integration tests for config_folders API (CRUD + project overrides)
- Integration tests for tool_types API with new fields
- Integration tests for tool_configs API with new fields

Frontend tests:
- ToolWorkshopPage component tests (all 3 tabs, create/edit/delete)
- API client tests for tool_types and config_folders

Fixes:
- Add field_validator import to tool_configs.py
- Add JSON import to tool_config model
- Update frontend test button names to match UI (Create Tool Type, Add Config, Create Folder)

Quality gates: backend unit tests passing (23/23)
2026-05-22 19:45:10 +02:00
miguel 2525c58471 feat(web): support ssh owner repo clone flow
- Add SSH-only owner/repo clone path for git.commumedia.org
- Preflight remote repository existence with git ls-remote before cloning
- Keep advanced URL paste fallback and blank repository creation
- Add focused backend and frontend coverage plus docs updates

Quality gates: python -m py_compile, vitest run src/components/repositories-settings-tab.test.tsx, npm run typecheck
2026-05-22 19:07:04 +02:00
Fusion 8dd350286e feat: implement tool workshop - comprehensive tool system enhancement
- Add Docker Compose and Dockerfile support for tool definitions
- Implement readiness probes with configurable command, timeout, interval
- Create ConfigFolder model for reusable file collections with project overrides
- Add rich tool config fields: port_override, start_command, working_directory, env vars, volumes
- Build unified Tool Workshop UI at /tool-workshop replacing /tool-configs and /tool-types
- Update instance creation to support dockerfile builds, config folder mounting, readiness probes
- Add 3 database migrations for tool_types, tool_configs, and new config_folders table
- Create docker_build.py and readiness_probe.py services
- Add config_folders API with CRUD and project override endpoints

Quality gates: frontend build passes, Python syntax valid, all phases complete

Addresses tool-workshop OpenSpec change
2026-05-22 19:06:45 +02:00
miguel 5af4de0d7e refactor(web): reuse repository create dialog
- Extract the repository create dialog into a shared component
- Reuse the same clone/validation flow in project settings and repository management pages
- Keep the shared UI covered with focused tests

Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx
2026-05-22 18:43:03 +02:00
miguel ebfe991a15 fix(web): support cloning existing repositories
- Make the add repository dialog explicitly support cloning from a remote git server
- Keep blank repo creation as a fallback option
- Add validation and tests for the clone-first flow

Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx
2026-05-22 18:38:21 +02:00
miguel cc1507a33e fix(web): add repository creation to project settings
- Add repository creation dialog to the project settings repositories tab
- Reuse shared repository API for list/create/delete operations
- Add coverage for the create flow in the settings tab

Quality gates: tsc --noEmit, vitest run src/components/repositories-settings-tab.test.tsx
2026-05-22 18:34:02 +02:00
Fusion ae377baa74 fix(opencode): add debugging output for installation
- Add set -x for verbose command execution
- Add error messages if npm install fails
- Check which opencode and list global bin directory
- Add npm global bin to PATH in bashrc
- Add ca-certificates package for HTTPS

Quality gates: code review
2026-05-21 12:40:01 +02:00
Fusion a0d4e76662 fix(opencode): ensure proper installation and workspace setup
- Add --unsafe-perm to npm install for global package installation
- Add cd /workspace to /root/.bashrc so terminal opens in repo directory
- Start container process in /workspace directory
- Use exec for proper signal handling

Quality gates: code review
2026-05-21 12:34:18 +02:00
Fusion b61c2256a8 refactor(opencode): install opencode via npm instead of curl
- Replace curl-based installation with npm install -g opencode-ai
- Remove manual PATH setup and symlink creation
- Simplify installation process

Quality gates: code review
2026-05-21 12:29:23 +02:00
Fusion 7e6588a99d fix(opencode): ensure opencode binary is in PATH
- Check if opencode binary exists at expected location before symlinking
- Add ~/.opencode/bin to PATH in /etc/profile and /root/.bashrc
- Provides warning if binary not found instead of silently failing

Quality gates: code review
2026-05-21 12:26:07 +02:00
Fusion 56b54e269a fix(terminal): create PTY for proper interactive shell
- Use Python pty module to create pseudo-terminal
- Pass slave fd to docker exec for real TTY allocation
- Fixes ioctl errors and job control warnings
- Supports terminal resizing via TIOCSWINSZ

Quality gates: local testing
2026-05-21 12:03:36 +02:00
Fusion 6e597b9e21 fix(terminal): allocate proper TTY for docker exec
- Change docker exec -i to -it for real TTY allocation
- Fixes ioctl errors and job control warnings in terminal
- Gives bash a proper terminal for interactive use

Quality gates: manually tested
2026-05-21 11:39:14 +02:00
Fusion a2c3d6877e fix(terminal): remove TTY allocation, use interactive bash with TERM env var 2026-05-21 11:21:51 +02:00
Fusion a200955ef2 fix(terminal): remove duplicate WebSocket read loop, let terminal_manager handle I/O 2026-05-21 11:19:11 +02:00
Fusion 76266fc3d0 fix(terminal): allocate pseudo-TTY for interactive docker exec session 2026-05-21 11:09:08 +02:00
Fusion 9d54a2542b fix(terminal): use decode_session_cookie instead of verify_session_token 2026-05-21 11:05:48 +02:00
Fusion 520078fd64 debug(terminal): add comprehensive logging to WebSocket terminal handler 2026-05-21 11:01:32 +02:00
Fusion 55c4dc1281 fix(git): auto-configure safe.directory when git detects dubious ownership 2026-05-21 10:55:14 +02:00
Fusion 74ac7b00bb fix(logging): add missing import logging to git_files.py 2026-05-21 10:51:27 +02:00
Fusion ecc0acc8dd fix(logging): add detailed error logging to git repository endpoints and utilities 2026-05-21 10:47:30 +02:00
Fusion fba01ddfb2 fix(git): handle empty repositories gracefully and show empty state in UI 2026-05-21 10:38:20 +02:00
Fusion 35a251a0b4 fix(sessions): add tool_type_interfaces to backend response and handle undefined in frontend 2026-05-21 10:19:35 +02:00
Fusion b10eadf64b fix(sessions): navigate to terminal page for terminal-only sessions 2026-05-21 10:14:00 +02:00
Fusion 50fcf5c077 fix(tunnels): skip tunnel creation for terminal-only tools 2026-05-20 18:11:33 +02:00
Fusion 19db7db8d0 fix(compose): remove external network declarations from compose templates 2026-05-20 17:58:36 +02:00
Fusion 189f29ee41 fix(opencode): update seed function to sync existing tool types with code changes 2026-05-20 17:55:18 +02:00
Fusion f2c3264be6 fix(opencode): add tar dependency and symlink binary to /usr/local/bin 2026-05-20 17:48:36 +02:00
Fusion f07a632c86 fix(opencode): use official install script from opencode.ai 2026-05-20 17:37:57 +02:00
Fusion 0b68efb6e0 fix(tunnels): add missing imports for render_compose_template, recreate_tunnel, check_tunnel_health 2026-05-20 17:30:34 +02:00
Fusion 54ec87a836 fix(migration): rename migration to fit within alembic version string limit 2026-05-20 17:24:04 +02:00
Fusion 3d64ec9061 fix(tunnels): connect tool containers to backend network for cloudflared access 2026-05-20 17:21:21 +02:00
Fusion ac6bd3304d feat(opencode-web-terminal): complete OpenCode web terminal implementation
- Update OpenCode compose template with web server on port 3000
- Add default_port=3000 and interfaces=[terminal, web] to OpenCode seed data
- Remove hardcoded 8080 fallback in tunnel creation
- Fail gracefully when tool type has no default_port configured
- Update frontend ToolType API to include default_port, category, interfaces
- Add port, category, and interfaces fields to tool type creation form
- Display port and interfaces in tool type cards
- Create migration 0012 to make default_port non-nullable
- Set default_port values for existing built-in tool types
- Quality gates: typecheck ✓, build ✓, Python syntax ✓
2026-05-20 17:17:50 +02:00
Fusion 6ec35988cc feat(sessions): add stop confirmation, health checks, and tunnel recreation
- Add inline confirmation dialog before stopping instances
- Delete instances from state immediately without page reload
- Add health check polling every 30s for running instances
- Show tunnel error badge when tunnel is unreachable
- Add 'Fix Tunnel' button to recreate broken tunnels
- Update API client with health check and tunnel recreation endpoints
2026-05-20 16:49:31 +02:00
Fusion e985f0122e debug(tunnels): add connectivity check before starting cloudflared tunnel 2026-05-20 16:28:39 +02:00
Fusion 2bd778117f feat(tunnels): switch to temporary Cloudflare tunnels
Replace persistent Cloudflare tunnels (API-based) with temporary tunnels using
'cloudflared tunnel --url'. This removes the need for Cloudflare API tokens,
DNS records, and persistent tunnel management.

Changes:
- Install cloudflared binary in API Dockerfile
- Add start_cloudflared_tunnel() and stop_cloudflared_tunnel() to docker.py
- Update instance start/stop/restart/delete to use temporary tunnels
- Store tunnel PID in tunnel_id field, temporary URL in url/public_url
- Remove Cloudflare API service (cloudflare_tunnel.py)
- Remove cloudflared container from docker-compose
- Remove Cloudflare env vars (CLOUDFLARE_API_TOKEN, ZONE_ID, etc.)
- Remove Cloudflare configuration from config.py
- Remove Cloudflare startup check from main.py
- Remove /health/cloudflare endpoint
2026-05-20 16:23:16 +02:00
Fusion 23ae12e69c debug(cloudflare): add detailed logging for tunnel creation failures 2026-05-20 16:09:47 +02:00
Fusion 65d4fad3c5 fix(cloudflare): fix delete_tunnel subdomain format and add startup config check
- Fix delete_tunnel calls to use correct subdomain format (instance-{id[:8]})
- Add Cloudflare configuration check at startup with clear warnings
- Help diagnose why tunnels aren't being created
2026-05-20 16:01:21 +02:00
Fusion e6f64c39f3 fix(cloudflare): add backend network to compose templates and improve tunnel diagnostics
- Add 'backend' external network to all compose templates so cloudflared can reach tool containers
- Add better error handling and logging to create_tunnel() with specific error messages for auth failures
- Add check_cloudflare_config() diagnostic function
- Add /health/cloudflare endpoint to verify Cloudflare configuration
- Import Any type for type hints
2026-05-20 15:55:20 +02:00
Fusion e7c42c17b9 fix(docker): copy Python packages to root home directory
Since API container runs as root (for Docker socket access),
copy Python packages to /root/.local instead of /home/appuser/.local
so uvicorn and other dependencies are in PATH.
2026-05-20 15:42:12 +02:00
Fusion f7be50952a fix(docker): run API container as root for Docker socket access
The API container needs to run docker compose commands via the
mounted Docker socket. Running as non-root user doesn't work well
with socket permissions across container boundaries.

- Remove USER appuser from Dockerfile (API service only)
- Remove group_add from docker-compose (no longer needed)
- Add security note about considering Docker-in-Docker or rootless

This fixes:
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
2026-05-20 15:38:02 +02:00
Fusion 402e662c0c fix(docker): add docker group to API container for socket access
The API container needs access to /var/run/docker.sock to run
docker compose commands for tool instances. Add group_add to
match the host's docker GID.

Error was:
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
2026-05-20 15:33:30 +02:00
Fusion 7450dd0ce5 fix(instances): add missing TOOL_NAME variable to compose template
The compose template uses {{TOOL_NAME}} for container_name but
we weren't passing it in the variables dict, causing YAML parse error.

Error was:
yaml: cannot use 'map[string]interface {}{"TOOL_NAME":interface {}(nil)}' as a map key
2026-05-20 15:30:45 +02:00
Fusion 7a88639250 chore(logging): add detailed debug logging to instance start and tunnel creation
Add comprehensive logging to trace 500 error:
- Log each step of docker compose up (returncode, stdout, stderr)
- Log container ID and name after start
- Log tool type and port being used
- Log each step of Cloudflare tunnel creation with API responses
- Log cloudflared config updates

This will help identify exactly where the failure occurs.
2026-05-20 15:27:55 +02:00
Fusion 1d33735e2f fix(cloudflare): use correct container port in tunnel config
Cloudflared was hardcoded to route to port 8080, but containers
listen on different ports (8443 for code-server, 8888 for jupyter).

- Add instance_port parameter to create_tunnel and update_cloudflared_config
- Fetch tool type default_port when creating tunnels
- Route to correct internal port instead of hardcoded 8080
2026-05-20 15:23:39 +02:00
Fusion d11b43b69f fix(settings): properly save and clear configuration values
Backend:
- Replace dict mutation with dict replacement to fix SQLAlchemy JSON
  mutation tracking issue (config.config = {**config.config, **update_data})

Frontend:
- Send explicit null values instead of undefined so fields can be cleared
- Update UserConfigUpdate interface to accept null values
2026-05-20 15:17:33 +02:00
Fusion 294d02f9fb feat(sessions): add confirmation dialog before deleting active sessions
Active Sessions now show inline confirm/cancel buttons before deletion,
matching the Recent Sessions UX pattern.
2026-05-20 15:14:15 +02:00
Fusion a10029f36c fix(sessions): use session project_id and repository_id for stop/delete
Fix 404 error when stopping or deleting instances from sessions page.
Was passing empty string for repoId instead of session.repository_id.
2026-05-20 15:11:57 +02:00
Fusion 6a2ebae8c9 fix(sessions): auto-start instances and add delete button
- Auto-start instances after creation from Sessions page
- Add delete button to Active Sessions section
- Fix instances remaining in 'pending' status
2026-05-20 15:07:52 +02:00
Fusion 1a922d4171 fix(sessions): correct session state categorization
- Backend now returns stopped and error sessions too
- Active sessions include running/building/pending
- Recent sessions show stopped/error only
- Status badge shows actual status (running/building/pending)
2026-05-20 14:49:30 +02:00
Fusion d906c12aa9 fix(sessions): open Cloudflare URLs directly in all session sections
- Fix Recent Sessions section to use anchor tag linking to instance URL
- Fix Last Session section to show URL and open it directly
- All Open buttons now link directly to Cloudflare URLs instead of navigating to project
2026-05-20 14:45:22 +02:00
Fusion 4906ce0cd9 feat(sessions): display and open Cloudflare URLs in session view
- Show Cloudflare URL in active session cards
- Change Open button to anchor tag linking directly to instance URL
- Add CSS styling for URL display in session cards
- Falls back to project navigation if no URL available
2026-05-20 14:38:33 +02:00
Fusion e7804c0f58 fix(sessions): open instance URL in new tab
Update handleOpen in SessionsPage to open the instance URL
in a new tab when available, instead of navigating to the
project page. Falls back to project navigation if no URL.
2026-05-20 14:28:09 +02:00
Fusion b40eb3e88c feat(cloudflare-tunnel): integrate Cloudflare tunnels for instance access
Backend:
- Add cloudflare_tunnel.py service for creating/deleting tunnels via Cloudflare API
- Add public_url and tunnel_id fields to ToolInstance model
- Update start_instance to create Cloudflare tunnel after container starts
- Update stop_instance to delete tunnel before stopping container
- Update delete_instance to cleanup tunnel before deletion
- Update restart_instance to recreate tunnel on restart
- Create Alembic migration 0011 for tunnel fields
- Add Cloudflare config settings (API token, zone ID, account ID, base domain)

Infrastructure:
- Add cloudflared service to docker-compose.traefik.yml
- Mount shared cloudflared_config volume between API and cloudflared containers
- Add Cloudflare env vars to API service

Frontend:
- Update instance Open button to handle both full URLs and proxy paths

The instance URL is now set to the Cloudflare tunnel public URL when available,
falling back to the API proxy path if tunnel creation fails.
2026-05-20 14:25:06 +02:00
Fusion 98795e31dd fix(proxy): use internal container port instead of host port
The proxy was using instance.port which is a dynamically allocated
host port (e.g., 10001). But containers communicate on the Docker
network using their internal ports (8443 for code-server, 8888 for
jupyter). This caused connection failures when opening instances.

- Add default_port field to ToolType model (null for terminal-only tools)
- Create migration 0010 for default_port column
- Update seed data: code-server=8443, jupyter=8888, opencode=null
- Update proxy to use tool type's default_port instead of instance.port
- Update frontend ToolType interface to include default_port

Fixes: Opening instances now routes to correct internal container port
2026-05-20 13:55:42 +02:00
Fusion b4a7627718 fix(sessions): stabilize setAllSessions to prevent polling loop
Wrap setAllSessions in useCallback so it has a stable reference.
This breaks the infinite re-render loop that was causing 4-10
requests per second to /users/me/sessions.
2026-05-20 13:41:24 +02:00
Fusion 9d27cd9fe8 fix: install Docker CLI and mount socket for instance management
- Install docker-ce-cli and docker-compose-plugin in API Dockerfile
- Mount /var/run/docker.sock into API container
- Add appuser to docker group for socket permissions
- Fixes FileNotFoundError when deleting instances
2026-05-20 11:32:58 +02:00
Fusion e3ef852eca fix: add missing repository_id and project_id to Session type in state
The state/sessions.tsx Session interface was missing repository_id and
project_id fields that were added to api/sessions.ts in the previous
commit. This caused a TypeScript build error when the app-shell tried
to pass API sessions to the state context.
2026-05-20 11:30:01 +02:00
Fusion f824a1e6fe fix: settings save and session deletion bugs
Settings save:
- Remove exclude_none=True from user_config.py model_dump() call
- Fixes fields not updating when cleared or set to null/undefined

Session deletion:
- Add project_id and repository_id to get_user_sessions response
- Update frontend Session interface with new fields
- Fix handleDelete to use IDs instead of names, resolving 404 errors
2026-05-20 11:20:43 +02:00
Fusion ad09ffa6ec feat(tool-configs): add frontend tool config management page
- Create ToolConfigsPage with tool type selector, config list, and add/edit form
- Support both env and file config types
- Add route /tool-configs and navigation item
- Update API client with tool config endpoints
- Build passes successfully
2026-05-20 11:13:25 +02:00
Fusion 63ae706dd0 feat(tool-config): add categories, interfaces, and config management
Add support for tool categories, interface types, and per-tool configuration.

Backend:
- Add category and interfaces fields to ToolType model
- Create ToolConfig model for storing tool-specific settings
- Add tool_configs API endpoints (CRUD)
- Update built-in tool types with categories and interfaces:
  - code-server: editor, [web]
  - jupyter-notebook: notebook, [web]
  - opencode: ai-assistant, [terminal]
- Update instance API to include tool type interfaces
- Create Alembic migrations 0008 and 0009

Frontend:
- Update ToolType and Session interfaces with new fields
- Conditionally show Open/Terminal buttons based on tool interfaces
- Add API client for tool configs

OpenSpec: tool-config-management change created and implemented.
2026-05-20 11:03:09 +02:00
Fusion 74b5d0dc8c feat(instance-proxy): add HTTP proxy for tool instances
Add API proxy endpoint so users can access running tool instances
through the backend API instead of internal Docker network.

Backend:
- Add container_name field to ToolInstance model
- Create /instances/{id}/proxy/{path:path} endpoint with ownership checks
- Proxy HTTP requests to containers via docker network using container names
- Support all HTTP methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
- Store proxy URL in instance.url instead of localhost
- Add Alembic migration 0007 for container_name column
- Add get_container_name() utility to docker.py

Frontend:
- Update Open button to use full proxy URL (API_BASE_URL + instance.url)

Closes instance-proxy OpenSpec change.
2026-05-20 10:37:21 +02:00
Fusion 25662e525c fix(docker): make instance directory configurable and writable
- Add INSTANCE_BASE_PATH config option (defaults to /data/instances)
- Update docker.py to use configured path instead of hardcoded 'data/instances'
- Update Dockerfile to create /data/instances and chown to appuser
- Add instance_data volume to docker-compose.traefik.yml and docker-compose.yml
- Set INSTANCE_BASE_PATH env var in both compose files

This fixes the PermissionError when creating tool instances because
appuser can now write to /data/instances.
2026-05-20 10:09:37 +02:00
Fusion d4e992a9e2 fix(cors): add API domain to CORS origins and improve instance error handling
- Add API base URL to CORS allowed origins alongside web base URL
- Add CORS origin logging on startup for debugging
- Wrap instance creation in try/except with detailed error logging
- Return proper error message instead of raw 500 for instance creation failures

This fixes CORS errors when the frontend makes cross-origin requests and
provides better diagnostics for instance creation failures.
2026-05-20 10:03:53 +02:00
Fusion 35ada0e662 fix(auth): set session cookie on redirect response
The OAuth callback was setting the session cookie on the 'response'
parameter but returning a brand new RedirectResponse, causing the
cookie to be lost. This created an infinite login loop where the
callback succeeded but /auth/me always returned 401.

- Set cookies on the RedirectResponse instead of the unused response param
- Remove unused 'response: Response' parameter from callback handler
- Fixes login loop in production with cross-domain cookies
2026-05-19 23:29:24 +02:00
Fusion c3e2264771 debug: add logging to auth /me endpoint to diagnose login loop 2026-05-19 23:25:30 +02:00
Fusion 76741d3ee6 debug: add logging to settings save to diagnose issue 2026-05-19 23:21:48 +02:00
Fusion 62752a8390 fix: add validation logging and debug info for tool instance creation
- Add RequestValidationError handler to log validation errors
- Add extra=ignore to CreateInstanceRequest to be more lenient
- Add logging to create_instance endpoint to see received data
- Add missing logger import in tool_instances.py
2026-05-19 23:17:08 +02:00
Fusion 461cb30b28 fix: dark mode theme switching and styling
- Apply theme immediately when saving settings (fixes theme not updating)
- Add dark mode CSS variables for success/warning/danger/info colors
- Fix shell-header background for dark mode
- Fix URL validation styles to use CSS variables
- Add explicit background/color to form inputs for dark mode support
- Quality gates: typecheck OK, lint OK, build OK
2026-05-19 23:13:10 +02:00
Fusion 94aa88c154 feat: add Sessions Hub page
- Add Sessions tab to navigation between Dashboard and Projects
- Show active session count badge in navigation
- Create SessionsPage with:
  - Last session section with resume button
  - Active sessions grid with open/stop actions
  - Recent sessions list
  - Create session form with project/repo/tool selectors
- Add last_session_id to user config
- Update UserConfig schemas (backend and frontend)
- Add comprehensive CSS for sessions page

Quality gates: typecheck ✓, lint ✓, build ✓
2026-05-19 23:06:54 +02:00
Fusion 4f695d7e62 fix: accept tool instance creation params in request body
The create_instance endpoint was expecting tool_type_id and display_name
as query parameters, but the frontend sends them in the JSON body.
Added CreateInstanceRequest Pydantic model to properly parse the request body.

Fixes 422 Unprocessable Content error on instance creation.
2026-05-19 22:55:47 +02:00
Fusion 7a48180dc3 fix: correct get_db_session import in tool_instances.py
Import get_db_session from src.auth.dependencies instead of src.database.
Fixes ImportError on application startup.
2026-05-19 21:39:29 +02:00
Fusion a0b0944709 fix: correct get_db_session import in terminal.py
Import get_db_session from src.auth.dependencies instead of src.database.
Fixes ImportError on application startup.
2026-05-19 21:37:00 +02:00
Fusion b79da51269 docs: mark api-documentation tasks complete 2026-05-19 21:31:57 +02:00
Fusion 40a940304b docs: comprehensive API documentation
- Create enhanced health endpoints with /health and /health/db
- Add comprehensive docstrings to all API endpoints
- Add Pydantic response models with Field descriptions
- Create apps/api/README.md with setup guide
- Create ADR-001 for session auth decision
- Create ADR-002 for async SQLAlchemy decision
- Quality gates: Python syntax OK, TypeScript OK
2026-05-19 21:31:20 +02:00
Fusion e344e961d6 feat: implement web terminal for tool instances
- Add TerminalSession backend service for docker exec subprocess management
- Add TerminalManager for WebSocket session lifecycle management
- Create WebSocket endpoint at /ws/tool-instances/{id}/terminal
- Add session cookie authentication and instance ownership verification
- Install xterm.js with fit and web-links addons
- Create TerminalComponent with xterm.js integration
- Create TerminalPage with full-screen terminal view
- Add terminal route at /instances/:id/terminal
- Add terminal button to InstanceList for running instances
- Add terminal and arrow-left icons to icon registry
- Add comprehensive terminal CSS styles (dark theme, responsive)

Quality gates: typecheck ✓, lint ✓, build ✓, Python syntax ✓
2026-05-19 21:11:29 +02:00
178 changed files with 17964 additions and 777 deletions
+25 -9
View File
@@ -16,34 +16,50 @@ RUN pip install --no-cache-dir --user -e ".[dev]"
# Production stage
FROM python:3.11-slim
# Create non-root user
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
# Create non-root user and add to docker group
RUN groupadd -r appgroup && useradd -r -g appgroup appuser \
&& groupadd -r docker || true \
&& usermod -aG docker appuser
WORKDIR /app
# Install runtime dependencies
# Install runtime dependencies including Docker CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
git \
netcat-openbsd \
ca-certificates \
curl \
gnupg \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& chmod a+r /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
&& chmod +x /usr/local/bin/cloudflared \
&& rm -rf /var/lib/apt/lists/*
# Copy dependencies from builder
COPY --from=builder /root/.local /home/appuser/.local
ENV PATH=/home/appuser/.local/bin:$PATH
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# Copy application code
COPY --chown=appuser:appgroup . .
# Create directories for repo storage
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
# Create directories for repo and instance storage
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
# Copy wait-for-db script
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
RUN chmod +x /usr/local/bin/wait-for-db.sh
# Switch to non-root user
USER appuser
# NOTE: Running as root to access Docker socket for managing tool instances
# This is required because Docker socket permissions require root or docker group membership
# which doesn't work well across container boundaries.
# Consider using Docker-in-Docker or rootless Docker for production hardening.
# Expose port
EXPOSE 8000
+252
View File
@@ -0,0 +1,252 @@
# Headquarter API
The backend API for Headquarter - a self-hosted platform for managing projects, git repositories, and development tools.
## Overview
Built with **FastAPI** and **SQLAlchemy** (async), using **PostgreSQL** for data storage and **Docker** for tool instance management.
### Tech Stack
- **Framework**: FastAPI (Python 3.12+)
- **Database**: PostgreSQL 15+ with asyncpg
- **ORM**: SQLAlchemy 2.0 (async)
- **Auth**: OAuth2 via Authentik with session cookies
- **Migrations**: Alembic
- **Tools**: Docker Compose for instance management
## Quick Start
### Prerequisites
- Python 3.12+
- PostgreSQL 15+ running locally
- Docker (for tool instances)
### Setup
```bash
cd apps/api
# Create virtual environment
python -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -e ".[dev]"
# Set up database
# Ensure PostgreSQL is running with a 'headquarter' database
# Run migrations
alembic upgrade head
# Start development server
uvicorn src.main:app --reload --port 8000
```
The API will be available at `http://localhost:8000`.
### Interactive Documentation
Once running, visit:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
- **OpenAPI JSON**: http://localhost:8000/openapi.json
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DATABASE_URL` | Yes | - | PostgreSQL connection string |
| `API_BASE_URL` | Yes | - | Public API URL (e.g., `https://api.example.com`) |
| `AUTHENTIK_DOMAIN` | Yes | - | Authentik server domain |
| `AUTHENTIK_CLIENT_ID` | Yes | - | OAuth2 client ID |
| `AUTHENTIK_CLIENT_SECRET` | Yes | - | OAuth2 client secret |
| `AUTHENTIK_APPLICATION_SLUG` | Yes | - | Authentik application slug |
| `WEB_BASE_URL` | Yes | - | Public frontend URL |
| `SESSION_SECRET` | Yes | - | Secret for session cookie signing |
| `COOKIE_DOMAIN` | No | - | Cookie domain (e.g., `.example.com`) |
| `UPLOAD_DIR` | No | `./uploads` | Directory for file uploads |
| `REPO_BASE_PATH` | No | `./repositories` | Base path for git repositories |
| `INSTANCES_BASE_PATH` | No | `./instances` | Base path for tool instances |
| `LOG_LEVEL` | No | `INFO` | Logging level |
## Development
### Running Tests
```bash
# Run all tests
pytest
# Run specific test category
pytest -m unit # Unit tests (no DB)
pytest -m integration # Integration tests (requires DB)
# Run with coverage
pytest --cov=src --cov-report=html
```
### Code Quality
```bash
# Format code
ruff format src tests
# Lint
ruff check src tests
# Type check
mypy src
```
### Database Migrations
```bash
# Create new migration
alembic revision --autogenerate -m "description"
# Apply migrations
alembic upgrade head
# Rollback one migration
alembic downgrade -1
# Show current revision
alembic current
```
## Architecture
### Directory Structure
```
src/
├── api/ # API endpoint routers
│ ├── auth.py # OAuth2 authentication
│ ├── dashboard.py # Dashboard summary
│ ├── git_repositories.py # Git repo management
│ ├── health.py # Health checks
│ ├── projects.py # Project CRUD
│ ├── ssh_keys.py # SSH key management
│ ├── terminal.py # WebSocket terminal
│ ├── tool_instances.py # Tool instance management
│ ├── tool_types.py # Tool type definitions
│ ├── user_config.py # User preferences
│ └── users.py # User profile
├── auth/ # Authentication logic
│ ├── cookies.py # Cookie utilities
│ ├── dependencies.py # Auth dependencies
│ ├── oidc.py # OpenID Connect
│ └── session.py # Session management
├── config.py # Application settings
├── database.py # Database setup
├── main.py # FastAPI application
├── models/ # SQLAlchemy models
├── schemas/ # Pydantic schemas
├── services/ # Business logic
│ ├── docker.py # Docker Compose management
│ ├── terminal_manager.py # Terminal sessions
│ └── terminal_session.py # Terminal I/O
└── utils/ # Utilities
├── git_control.py # Git operations
├── git_files.py # File operations
├── git_history.py # History extraction
└── git_url_parser.py # URL parsing
```
### Authentication Flow
1. User clicks "Login" → redirects to Authentik OAuth
2. Authentik redirects back with authorization code
3. API exchanges code for tokens and fetches user info
4. API creates session cookie (HMAC-signed, httpOnly)
5. Frontend stores nothing - cookie sent automatically
6. Subsequent requests include cookie for authentication
### Data Flow
```
Client → FastAPI Router → Auth Dependency → Service Layer → Database
Pydantic Models (validation)
SQLAlchemy Models (ORM)
PostgreSQL (storage)
```
## API Endpoints
### Authentication
- `GET /auth/login` - Initiate OAuth login
- `GET /auth/callback` - OAuth callback
- `GET /auth/me` - Get current user
- `POST /auth/logout` - Logout
### Projects
- `GET /projects` - List projects
- `POST /projects` - Create project
- `GET /projects/{id}` - Get project
- `PUT /projects/{id}` - Update project
- `DELETE /projects/{id}` - Delete project
### Git Repositories
- `GET /projects/{id}/repositories` - List repositories
- `POST /projects/{id}/repositories` - Create repository
- `GET /projects/{id}/repositories/{id}` - Get repository
- `DELETE /projects/{id}/repositories/{id}` - Delete repository
- `GET /projects/{id}/repositories/{id}/files` - List files
- `GET /projects/{id}/repositories/{id}/files/content` - Get file content
- `POST /projects/{id}/repositories/{id}/files/content` - Update file
- `GET /projects/{id}/repositories/{id}/branches` - List branches
- `GET /projects/{id}/repositories/{id}/history` - Commit history
- `GET /projects/{id}/repositories/{id}/commits/{hash}` - Commit detail
### Tool Types
- `GET /tool-types` - List tool types
- `POST /tool-types` - Create tool type
- `GET /tool-types/{id}` - Get tool type
- `PUT /tool-types/{id}` - Update tool type
- `DELETE /tool-types/{id}` - Delete tool type
### Tool Instances
- `GET /tool-instances` - List instances
- `POST /tool-instances` - Create instance
- `GET /tool-instances/{id}` - Get instance
- `POST /tool-instances/{id}/start` - Start instance
- `POST /tool-instances/{id}/stop` - Stop instance
- `POST /tool-instances/{id}/restart` - Restart instance
- `DELETE /tool-instances/{id}` - Delete instance
- `GET /tool-instances/{id}/logs` - Get logs
### Terminal
- `WS /ws/tool-instances/{id}/terminal` - WebSocket terminal
### Users
- `GET /users/me` - Get profile
- `PUT /users/me` - Update profile
- `POST /users/me/avatar` - Upload avatar
- `GET /users/me/config` - Get config
- `PATCH /users/me/config` - Update config
### SSH Keys
- `GET /ssh-keys` - List keys
- `POST /ssh-keys` - Create key
- `DELETE /ssh-keys/{id}` - Delete key
### Health
- `GET /health` - System health
- `GET /health/db` - Database health
## Deployment
See the [deployment documentation](../../docs/deployment/) for Docker and Traefik setup.
## Contributing
1. Follow PEP 8 style guide
2. Add tests for new endpoints
3. Update documentation
4. Run quality gates before committing
@@ -0,0 +1,28 @@
"""add container_name to tool_instances
Revision ID: 0007_instance_container_name
Revises: 0006_tool_instances
Create Date: 2026-05-20 08:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0007_instance_container_name"
down_revision: Union[str, None] = "0006_tool_instances"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_instances",
sa.Column("container_name", sa.String(255), nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_instances", "container_name")
@@ -0,0 +1,33 @@
"""add category and interfaces to tool_types
Revision ID: 0008_tool_type_category
Revises: 0007_instance_container_name
Create Date: 2026-05-20 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0008_tool_type_category"
down_revision: Union[str, None] = "0007_instance_container_name"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_types",
sa.Column("category", sa.String(50), nullable=False, server_default="other")
)
op.add_column(
"tool_types",
sa.Column("interfaces", sa.JSON(), nullable=False, server_default='["web"]')
)
def downgrade() -> None:
op.drop_column("tool_types", "interfaces")
op.drop_column("tool_types", "category")
@@ -0,0 +1,46 @@
"""add tool_configs table
Revision ID: 0009_tool_configs
Revises: 0008_tool_type_category
Create Date: 2026-05-20 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0009_tool_configs"
down_revision: Union[str, None] = "0008_tool_type_category"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tool_configs",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("key", sa.String(255), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("config_type", sa.String(20), nullable=False, server_default="env"),
sa.Column("file_path", sa.String(1024), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_tool_configs_user_tool", "tool_configs", ["user_id", "tool_type_id"])
op.create_index("idx_tool_configs_project", "tool_configs", ["project_id"])
def downgrade() -> None:
op.drop_index("idx_tool_configs_project", table_name="tool_configs")
op.drop_index("idx_tool_configs_user_tool", table_name="tool_configs")
op.drop_table("tool_configs")
@@ -0,0 +1,28 @@
"""add default_port to tool_types
Revision ID: 0010_tool_type_default_port
Revises: 0009_tool_configs
Create Date: 2026-05-20 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0010_tool_type_default_port"
down_revision: Union[str, None] = "0009_tool_configs"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_types",
sa.Column("default_port", sa.Integer(), nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_types", "default_port")
@@ -0,0 +1,33 @@
"""add tunnel fields to tool_instances
Revision ID: 0011_tool_instance_tunnel_fields
Revises: 0010_tool_type_default_port
Create Date: 2026-05-20 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0011_tool_instance_tunnel_fields"
down_revision: Union[str, None] = "0010_tool_type_default_port"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_instances",
sa.Column("public_url", sa.String(1024), nullable=True)
)
op.add_column(
"tool_instances",
sa.Column("tunnel_id", sa.String(255), nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_instances", "tunnel_id")
op.drop_column("tool_instances", "public_url")
@@ -0,0 +1,48 @@
"""make default_port non-nullable and set values
Revision ID: 0012_default_port_req
Revises: 0011_tool_instance_tunnel_fields
Create Date: 2026-05-20 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0012_default_port_req"
down_revision: Union[str, None] = "0011_tool_instance_tunnel_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Set default_port for existing built-in tool types
op.execute("""
UPDATE tool_types
SET default_port = CASE
WHEN name = 'code-server' THEN 8443
WHEN name = 'jupyter-notebook' THEN 8888
WHEN name = 'opencode' THEN 3000
ELSE 8080
END
WHERE default_port IS NULL
""")
# Make default_port non-nullable
op.alter_column(
"tool_types",
"default_port",
existing_type=sa.Integer(),
nullable=False,
)
def downgrade() -> None:
op.alter_column(
"tool_types",
"default_port",
existing_type=sa.Integer(),
nullable=True,
)
@@ -0,0 +1,104 @@
"""add config profiles, includes, mounts, and tool instance profile selection
Revision ID: 0013_add_config_profiles
Revises: 0012_default_port_req
Create Date: 2026-05-24 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0013_add_config_profiles"
down_revision: Union[str, None] = "0012_default_port_req"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create config_profiles table
op.create_table(
"config_profiles",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
# Create config_includes table
op.create_table(
"config_includes",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
)
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
# Create config_mounts table
op.create_table(
"config_mounts",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("mount_path", sa.String(length=1024), nullable=False),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
# Add selected_profile_id to tool_instances
op.add_column(
"tool_instances",
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_tool_instances_selected_profile",
"tool_instances",
"config_profiles",
["selected_profile_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"])
def downgrade() -> None:
# Remove selected_profile_id from tool_instances
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey")
op.drop_column("tool_instances", "selected_profile_id")
# Drop config_mounts
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
op.drop_table("config_mounts")
# Drop config_includes
op.drop_index("idx_config_includes_included", table_name="config_includes")
op.drop_index("idx_config_includes_profile", table_name="config_includes")
op.drop_table("config_includes")
# Drop config_profiles
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
op.drop_table("config_profiles")
@@ -0,0 +1,40 @@
"""add_tool_config_fields
Revision ID: 398082499c30
Revises: af8512103d67
Create Date: 2026-05-22 18:38:20.166184
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '398082499c30'
down_revision = 'af8512103d67'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add new columns to tool_configs
op.add_column('tool_configs', sa.Column('port_override', sa.Integer(), nullable=True))
op.add_column('tool_configs', sa.Column('start_command', sa.Text(), nullable=True))
op.add_column('tool_configs', sa.Column('working_directory', sa.Text(), nullable=True))
op.add_column('tool_configs', sa.Column('environment_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
op.add_column('tool_configs', sa.Column('volumes', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='[]'))
# Add CHECK constraint for port range
op.create_check_constraint('chk_port_range', 'tool_configs', sa.text('port_override IS NULL OR (port_override >= 1 AND port_override <= 65535)'))
def downgrade() -> None:
# Drop CHECK constraint
op.drop_constraint('chk_port_range', 'tool_configs', type_='check')
# Drop columns
op.drop_column('tool_configs', 'port_override')
op.drop_column('tool_configs', 'start_command')
op.drop_column('tool_configs', 'working_directory')
op.drop_column('tool_configs', 'environment_variables')
op.drop_column('tool_configs', 'volumes')
@@ -0,0 +1,44 @@
"""create_config_folders_table
Revision ID: 8ed7dd80973d
Revises: 398082499c30
Create Date: 2026-05-22 18:38:22.133696
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '8ed7dd80973d'
down_revision = '398082499c30'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'config_folders',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('mount_path', sa.String(1024), nullable=False),
sa.Column('files', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
sa.Column('project_overrides', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
sa.UniqueConstraint('user_id', 'name', name='uq_config_folders_user_name')
)
# Add index on user_id for filtering
op.create_index('idx_config_folders_user', 'config_folders', ['user_id'])
def downgrade() -> None:
# Drop index
op.drop_index('idx_config_folders_user', table_name='config_folders')
# Drop table
op.drop_table('config_folders')
@@ -0,0 +1,38 @@
"""add_tool_type_fields
Revision ID: af8512103d67
Revises: 0012_default_port_req
Create Date: 2026-05-22 18:37:56.607240
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'af8512103d67'
down_revision = '0012_default_port_req'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add new columns to tool_types
op.add_column('tool_types', sa.Column('definition_type', sa.String(20), nullable=False, server_default='compose'))
op.add_column('tool_types', sa.Column('dockerfile_template', sa.Text(), nullable=True))
op.add_column('tool_types', sa.Column('build_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
op.add_column('tool_types', sa.Column('readiness_probe', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
# Add CHECK constraint for definition_type
op.create_check_constraint('chk_definition_type', 'tool_types', sa.text("definition_type IN ('compose', 'dockerfile')"))
def downgrade() -> None:
# Drop CHECK constraint
op.drop_constraint('chk_definition_type', 'tool_types', type_='check')
# Drop columns
op.drop_column('tool_types', 'definition_type')
op.drop_column('tool_types', 'dockerfile_template')
op.drop_column('tool_types', 'build_context')
op.drop_column('tool_types', 'readiness_probe')
+34 -10
View File
@@ -25,8 +25,21 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
yield session
@router.get("/login")
@router.get(
"/login",
summary="Initiate OAuth login",
description="Redirects to the configured OAuth provider (Authentik) to start the authentication flow.",
response_class=RedirectResponse,
)
async def login(next: str = "/") -> RedirectResponse:
"""Initiate OAuth2 login flow.
Args:
next: URL to redirect to after successful authentication.
Returns:
RedirectResponse to the OAuth provider's authorization endpoint.
"""
settings = Settings()
redirect_uri = f"{settings.api_base_url}/auth/callback"
state = token_urlsafe(24)
@@ -46,7 +59,6 @@ async def login(next: str = "/") -> RedirectResponse:
async def callback(
code: str,
state: str,
response: Response,
auth_state: str | None = Cookie(default=None),
auth_next: str | None = Cookie(default="/"),
session: AsyncSession = Depends(get_db_session),
@@ -116,7 +128,13 @@ async def callback(
cookie_secure = bool(cookie_options["secure"])
cookie_domain = str(cookie_options["domain"]) if cookie_options.get("domain") else None
response.set_cookie(
logger.info("Auth callback complete for user id=%s, redirecting to %s", user.id, auth_next)
# Redirect to frontend with the original next path
redirect_url = f"{settings.web_base_url}{auth_next}"
redirect_response = RedirectResponse(url=redirect_url)
redirect_response.set_cookie(
"session",
session_cookie,
httponly=True,
@@ -124,14 +142,10 @@ async def callback(
secure=cookie_secure,
domain=cookie_domain,
)
response.delete_cookie("auth_state", samesite="lax", domain=cookie_domain)
response.delete_cookie("auth_next", samesite="lax", domain=cookie_domain)
logger.info("Auth callback complete for user id=%s, redirecting to %s", user.id, auth_next)
redirect_response.delete_cookie("auth_state", samesite="lax", domain=cookie_domain)
redirect_response.delete_cookie("auth_next", samesite="lax", domain=cookie_domain)
# Redirect to frontend with the original next path
redirect_url = f"{settings.web_base_url}{auth_next}"
return RedirectResponse(url=redirect_url)
return redirect_response
@router.post("/logout")
@@ -151,20 +165,30 @@ async def me(
session_cookie: str | None = Cookie(default=None, alias="session"),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, Any]:
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
if not session_cookie:
logger.warning("Auth /me: missing session cookie")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
settings = Settings()
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
try:
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
user_id = payload["user_id"]
logger.info("Auth /me: decoded session for user_id=%s", user_id)
except ValueError as exc:
logger.warning("Auth /me: invalid session: %s", exc)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
user = await session.get(User, user_id)
if user is None:
logger.warning("Auth /me: user not found for id=%s", user_id)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
logger.info("Auth /me: success for user=%s", user.email)
return {
"user": {
"id": str(user.id),
+372
View File
@@ -0,0 +1,372 @@
"""Config folder API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_folder import ConfigFolder
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
MAX_FOLDER_SIZE_MB = 10
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
class ConfigFolderCreate(BaseModel):
name: str = Field(description="Folder name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
mount_path: str = Field(description="Default mount path in container")
files: dict = Field(default_factory=dict, description="Files as {path: content}")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
total_size = 0
for path, content in v.items():
# Check for path traversal
if ".." in path or path.startswith("/"):
raise ValueError(f"Invalid file path: {path}")
total_size += len(content.encode("utf-8"))
if total_size > MAX_FOLDER_SIZE_BYTES:
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
return v
class ConfigFolderUpdate(BaseModel):
name: str | None = Field(default=None, description="Folder name")
description: str | None = Field(default=None, description="Optional description")
mount_path: str | None = Field(default=None, description="Default mount path")
files: dict | None = Field(default=None, description="Files as {path: content}")
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
if v is None:
return v
total_size = 0
for path, content in v.items():
# Check for path traversal
if ".." in path or path.startswith("/"):
raise ValueError(f"Invalid file path: {path}")
total_size += len(content.encode("utf-8"))
if total_size > MAX_FOLDER_SIZE_BYTES:
raise ValueError(f"Total folder size exceeds {MAX_FOLDER_SIZE_MB}MB limit")
return v
class ProjectOverrideCreate(BaseModel):
mount_path: str | None = Field(default=None, description="Override mount path")
files: dict = Field(default_factory=dict, description="Override files")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Mount path must be absolute (start with /)")
return v
class ConfigFolderResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
mount_path: str
files: dict
project_overrides: dict | None
is_active: bool
created_at: str
updated_at: str
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
async def list_config_folders(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List config folders for the current user."""
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
result = await session.execute(query)
folders = result.scalars().all()
return {
"folders": [
{
"id": str(f.id),
"user_id": str(f.user_id),
"name": f.name,
"description": f.description,
"mount_path": f.mount_path,
"files": f.files,
"project_overrides": f.project_overrides,
"is_active": f.is_active,
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in folders
]
}
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
async def create_config_folder(
data: ConfigFolderCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a config folder."""
# Check for duplicate name
existing = await session.scalar(
select(ConfigFolder).where(
ConfigFolder.user_id == user_id,
ConfigFolder.name == data.name,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config folder with name '{data.name}' already exists"
)
folder = ConfigFolder(
user_id=user_id,
name=data.name,
description=data.description,
mount_path=data.mount_path,
files=data.files,
)
session.add(folder)
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
async def update_config_folder(
folder_id: uuid.UUID,
data: ConfigFolderUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a config folder."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
if data.name is not None:
folder.name = data.name
if data.description is not None:
folder.description = data.description
if data.mount_path is not None:
folder.mount_path = data.mount_path
if data.files is not None:
folder.files = data.files
if data.is_active is not None:
folder.is_active = data.is_active
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_folder(
folder_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a config folder."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
await session.delete(folder)
await session.commit()
class ProjectOverrideWithId(ProjectOverrideCreate):
project_id: uuid.UUID = Field(description="Project ID for the override")
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
async def get_config_folder(
folder_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a config folder by ID."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
async def add_project_override(
folder_id: uuid.UUID,
data: ProjectOverrideWithId,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Add a project override to a config folder."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Initialize project_overrides if None
if folder.project_overrides is None:
folder.project_overrides = {}
# Add/update override
override_data = {}
if data.mount_path is not None:
override_data["mount_path"] = data.mount_path
if data.files is not None:
override_data["files"] = data.files
# Use a copy to trigger SQLAlchemy change detection on JSONB
current_overrides = dict(folder.project_overrides or {})
current_overrides[str(data.project_id)] = override_data
folder.project_overrides = current_overrides
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides,
}
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
async def update_project_override(
folder_id: uuid.UUID,
project_id: uuid.UUID,
data: ProjectOverrideCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a project override."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Initialize project_overrides if None
if folder.project_overrides is None:
folder.project_overrides = {}
# Update override
current_overrides = dict(folder.project_overrides or {})
override_data = current_overrides.get(str(project_id), {})
if data.mount_path is not None:
override_data["mount_path"] = data.mount_path
if data.files is not None:
override_data["files"] = data.files
current_overrides[str(project_id)] = override_data
folder.project_overrides = current_overrides
# Mark the field as modified to ensure SQLAlchemy detects the change
from sqlalchemy.orm.attributes import flag_modified
flag_modified(folder, "project_overrides")
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides,
}
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
async def remove_project_override(
folder_id: uuid.UUID,
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Remove a project override."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Remove override if exists
current_overrides = dict(folder.project_overrides or {})
if str(project_id) in current_overrides:
del current_overrides[str(project_id)]
folder.project_overrides = current_overrides
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides or {},
}
+14 -1
View File
@@ -12,11 +12,24 @@ from src.models.ssh_key import SSHKey
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/summary")
@router.get(
"/summary",
summary="Get dashboard summary",
description="Get a summary of the user's projects, repositories, SSH keys, and recent activity.",
)
async def get_dashboard_summary(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a summary of the user's dashboard data.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with counts of projects, repositories, SSH keys, and recent activity.
"""
# Count user's projects
projects_result = await session.execute(
select(func.count()).select_from(Project).where(Project.owner_id == user_id)
+462 -63
View File
@@ -1,3 +1,4 @@
import logging
import os
import shutil
import subprocess
@@ -36,8 +37,11 @@ from src.utils.git_url_parser import parse_git_url
router = APIRouter(prefix="/projects", tags=["git-repositories"])
logger = logging.getLogger(__name__)
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -49,6 +53,19 @@ async def _get_owned_project(
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
@@ -58,10 +75,102 @@ async def _get_owned_project(
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
"""Generate the filesystem path for a repository.
Args:
user_id: UUID of the repository owner.
project_id: UUID of the project.
name: Repository name.
Returns:
Absolute path to the repository directory.
"""
base = Settings().repo_base_path or "/data/repos"
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
def _build_provider_clone_url(owner: str, repo: str) -> str:
"""Build the SSH clone URL for the fixed git provider."""
return f"git@git.commumedia.org:{owner}/{repo}.git"
def _preflight_remote_repository(remote_url: str) -> None:
"""Verify a remote repository is reachable before cloning."""
try:
result = subprocess.run(
["git", "ls-remote", remote_url],
capture_output=True,
text=True,
timeout=60,
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
if result.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="repository not found or inaccessible",
)
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
try:
result = subprocess.run(
["git", "clone", remote_url, repo_path],
capture_output=True,
text=True,
timeout=300,
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
if result.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}",
)
def _init_working_repository(repo_path: str) -> None:
try:
result = subprocess.run(
["git", "init", "-b", "main", repo_path],
capture_output=True,
text=True,
)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
if result.returncode == 0:
return
fallback = subprocess.run(
["git", "init", repo_path],
capture_output=True,
text=True,
)
if fallback.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to initialize repository: {fallback.stderr}",
)
ref_result = subprocess.run(
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
capture_output=True,
text=True,
)
if ref_result.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to set initial branch: {ref_result.stderr}",
)
class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
@@ -97,12 +206,27 @@ class GitRepositoryResponse(BaseModel):
updated_at: datetime
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
@router.get(
"/{project_id}/repositories",
response_model=list[GitRepositoryResponse],
summary="List repositories",
description="List all git repositories in a project.",
)
async def list_repositories(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[GitRepository]:
"""List all repositories in a project.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of repositories in the project.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -112,13 +236,29 @@ async def list_repositories(
return list(result.scalars().all())
@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{project_id}/repositories/{repo_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a repository",
description="Delete a git repository from the project and remove it from disk.",
)
async def delete_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Delete a repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Empty response with 204 status code.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -135,20 +275,49 @@ async def delete_repository(
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/repositories/parse-url", response_model=URLParseResponse)
@router.post(
"/repositories/parse-url",
response_model=URLParseResponse,
summary="Parse a git URL",
description="Parse a git URL and detect if it's a browser URL that needs correction.",
)
async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
"""Parse a git URL and detect if it's a browser URL that needs correction."""
"""Parse a git URL and detect if it's a browser URL that needs correction.
Args:
data: Request containing the URL to parse.
Returns:
Parsed URL information including whether it needs parsing and suggested corrections.
"""
result = parse_git_url(data.url)
return URLParseResponse(**result)
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/{project_id}/repositories",
response_model=GitRepositoryResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a repository",
description="Create a new git repository in a project. Can clone from remote or initialize a working repository.",
)
async def create_repository(
project_id: uuid.UUID,
data: GitRepositoryCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> GitRepository:
"""Create a new git repository.
Args:
project_id: UUID of the project.
data: Repository creation data including name and optional remote URL.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created repository.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -180,47 +349,25 @@ async def create_repository(
if parse_result["base_url"]:
remote_url = parse_result["base_url"]
if remote_url:
_preflight_remote_repository(remote_url)
repo_path = _get_repo_path(user_id, project_id, data.name)
# Ensure parent directory exists
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
if remote_url:
# Clone as mirror
try:
result = subprocess.run(
["git", "clone", "--mirror", remote_url, repo_path],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}",
)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
_clone_working_repository(remote_url, repo_path)
else:
# Init bare repo
try:
result = subprocess.run(
["git", "init", "--bare", repo_path],
capture_output=True,
text=True,
check=True,
)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
_init_working_repository(repo_path)
repo = GitRepository(
name=data.name,
path=repo_path,
project_id=project_id,
owner_id=user_id,
is_mirror=bool(remote_url),
is_mirror=False,
remote_url=remote_url,
)
session.add(repo)
@@ -229,7 +376,11 @@ async def create_repository(
return repo
@router.get("/{project_id}/repositories/{repo_id}/history")
@router.get(
"/{project_id}/repositories/{repo_id}/history",
summary="Get repository history",
description="Get commit history for a repository with optional branch filtering.",
)
async def get_repository_history(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -240,7 +391,21 @@ async def get_repository_history(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get commit history for a repository."""
"""Get commit history for a repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
view: View type for history display (default: graph).
branch: Optional branch name to filter commits.
limit: Maximum number of commits to return (default: 100).
offset: Number of commits to skip (default: 0).
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing commit history data.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -258,7 +423,11 @@ async def get_repository_history(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
@router.get(
"/{project_id}/repositories/{repo_id}/commits/{commit_hash}",
summary="Get commit details",
description="Get detailed information about a specific commit.",
)
async def get_repository_commit(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -266,7 +435,18 @@ async def get_repository_commit(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get detailed information about a specific commit."""
"""Get detailed information about a specific commit.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
commit_hash: Hash of the commit to retrieve.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary containing commit details.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -322,7 +502,12 @@ class FileUpdateResponse(BaseModel):
branch: str
@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/files",
response_model=FileListResponse,
summary="List repository files",
description="List files and directories in a repository path.",
)
async def list_repository_files(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -331,7 +516,19 @@ async def list_repository_files(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FileListResponse:
"""List files and directories in a repository path."""
"""List files and directories in a repository path.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch: Branch name to browse (default: main).
path: Directory path within the repository (default: root).
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of files and directories in the specified path.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -360,10 +557,23 @@ async def list_repository_files(
],
)
except RuntimeError as e:
logger.error(
"Failed to list files for repo %s (path=%s, branch=%s): %s",
repo_id,
path,
branch,
str(e),
exc_info=True,
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/files/content",
response_model=FileContentResponse,
summary="Get file content",
description="Get the content of a file in a repository.",
)
async def get_repository_file_content(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -372,7 +582,19 @@ async def get_repository_file_content(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FileContentResponse:
"""Get the content of a file."""
"""Get the content of a file.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch: Branch name where the file is located.
path: File path within the repository.
user_id: ID of the authenticated user.
session: Database session.
Returns:
File content and metadata.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -401,14 +623,29 @@ async def get_repository_file_content(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{project_id}/repositories/{repo_id}/branches", response_model=BranchesResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/branches",
response_model=BranchesResponse,
summary="List branches",
description="List all branches in the repository.",
)
async def get_repository_branches(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> BranchesResponse:
"""List all branches in the repository."""
"""List all branches in the repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of branches and the default branch name.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -433,10 +670,21 @@ async def get_repository_branches(
default_branch=default_branch,
)
except RuntimeError as e:
logger.error(
"Failed to list branches for repo %s: %s",
repo_id,
str(e),
exc_info=True,
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/files/content",
response_model=FileUpdateResponse,
summary="Update file content",
description="Update a file and create a commit.",
)
async def update_repository_file(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -444,7 +692,18 @@ async def update_repository_file(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FileUpdateResponse:
"""Update a file and create a commit."""
"""Update a file and create a commit.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: File update data including path, branch, content, and commit message.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Commit information for the file update.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -493,14 +752,29 @@ class StatusResponse(BaseModel):
behind: int
@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse)
@router.get(
"/{project_id}/repositories/{repo_id}/status",
response_model=StatusResponse,
summary="Get repository status",
description="Get the working directory status including modified, added, and deleted files.",
)
async def get_repository_status(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> StatusResponse:
"""Get the working directory status."""
"""Get the working directory status.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Repository status including branch, modified files, and ahead/behind counts.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -536,7 +810,11 @@ class CheckoutRequest(BaseModel):
branch: str
@router.post("/{project_id}/repositories/{repo_id}/branches")
@router.post(
"/{project_id}/repositories/{repo_id}/branches",
summary="Create a branch",
description="Create a new branch in the repository.",
)
async def create_repository_branch(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -544,7 +822,18 @@ async def create_repository_branch(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a new branch."""
"""Create a new branch.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Branch creation data including name and optional base branch.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with success message and branch name.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -562,7 +851,11 @@ async def create_repository_branch(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}")
@router.delete(
"/{project_id}/repositories/{repo_id}/branches/{branch_name}",
summary="Delete a branch",
description="Delete a branch from the repository.",
)
async def delete_repository_branch(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -571,7 +864,19 @@ async def delete_repository_branch(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Delete a branch."""
"""Delete a branch.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch_name: Name of the branch to delete.
force: Whether to force delete the branch.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with success message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -589,7 +894,11 @@ async def delete_repository_branch(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/{project_id}/repositories/{repo_id}/checkout")
@router.post(
"/{project_id}/repositories/{repo_id}/checkout",
summary="Checkout a branch",
description="Checkout a branch in the repository.",
)
async def checkout_repository_branch(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -597,7 +906,18 @@ async def checkout_repository_branch(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Checkout a branch."""
"""Checkout a branch.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Checkout request containing the branch name.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Dictionary with success message and checked out branch name.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -625,7 +945,12 @@ class CommitResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/commit",
response_model=CommitResponse,
summary="Commit changes",
description="Commit changes to the repository.",
)
async def commit_repository_changes(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -633,7 +958,18 @@ async def commit_repository_changes(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> CommitResponse:
"""Commit changes to the repository."""
"""Commit changes to the repository.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Commit request containing message and optional files to commit.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Commit information including hash and message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -669,14 +1005,29 @@ class FetchResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/fetch",
response_model=FetchResponse,
summary="Fetch from remote",
description="Fetch updates from the remote repository.",
)
async def fetch_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> FetchResponse:
"""Fetch from remote."""
"""Fetch from remote.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Success message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -698,7 +1049,12 @@ class PullResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/pull",
response_model=PullResponse,
summary="Pull from remote",
description="Pull updates from the remote repository.",
)
async def pull_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -706,7 +1062,18 @@ async def pull_repository(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> PullResponse:
"""Pull updates from remote."""
"""Pull updates from remote.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch: Optional branch name to pull.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Success message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -728,7 +1095,12 @@ class PushResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/push",
response_model=PushResponse,
summary="Push to remote",
description="Push changes to the remote repository.",
)
async def push_repository(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -736,7 +1108,18 @@ async def push_repository(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> PushResponse:
"""Push changes to remote."""
"""Push changes to remote.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
branch: Optional branch name to push.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Success message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
@@ -765,7 +1148,12 @@ class MergeResponse(BaseModel):
message: str
@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse)
@router.post(
"/{project_id}/repositories/{repo_id}/merge",
response_model=MergeResponse,
summary="Merge branches",
description="Merge one branch into another.",
)
async def merge_repository_branches(
project_id: uuid.UUID,
repo_id: uuid.UUID,
@@ -773,7 +1161,18 @@ async def merge_repository_branches(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> MergeResponse:
"""Merge branches."""
"""Merge branches.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
data: Merge request containing source branch, optional target branch, and message.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Merge result with commit hash and message.
"""
_user = await _get_user(session, user_id)
_project = await _get_owned_project(project_id, user_id, session)
+149
View File
@@ -0,0 +1,149 @@
"""Health check endpoints and models."""
import time
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, status
from pydantic import BaseModel, Field
from sqlalchemy import text
from src.config import Settings
from src.database import SessionLocal
router = APIRouter()
# Track start time for uptime
_start_time = time.time()
class DatabaseHealth(BaseModel):
"""Database health check result."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
class DiskHealth(BaseModel):
"""Disk space health check result."""
status: str = Field(description="Disk health status", examples=["healthy"])
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
class HealthChecks(BaseModel):
"""Individual health checks."""
database: DatabaseHealth | None = None
disk: DiskHealth | None = None
class HealthResponse(BaseModel):
"""Overall health check response."""
status: str = Field(description="Overall health status", examples=["healthy"])
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
version: str = Field(description="API version", examples=["0.1.0"])
checks: HealthChecks = Field(description="Individual health checks")
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
class DatabaseHealthResponse(BaseModel):
"""Database-specific health check response."""
status: str = Field(description="Database health status", examples=["healthy"])
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
@router.get(
"/health",
response_model=HealthResponse,
summary="Health check",
description="Returns overall system health status including database and disk checks.",
tags=["Health"],
)
async def health_check() -> dict[str, Any]:
"""Check overall system health.
Returns:
HealthResponse with status, timestamp, version, checks, and uptime.
"""
checks = HealthChecks()
overall_status = "healthy"
# Database check
try:
import time as time_module
start = time_module.perf_counter()
async with SessionLocal() as session:
await session.execute(text("SELECT 1"))
db_time = (time_module.perf_counter() - start) * 1000
checks.database = DatabaseHealth(
status="healthy",
response_time_ms=round(db_time, 2),
)
except Exception:
checks.database = DatabaseHealth(
status="unhealthy",
response_time_ms=0.0,
)
overall_status = "degraded"
# Disk check
try:
import shutil
disk = shutil.disk_usage("/")
free_gb = disk.free / (1024**3)
total_gb = disk.total / (1024**3)
disk_status = "healthy" if free_gb > 1.0 else "degraded"
if disk_status == "degraded":
overall_status = "degraded"
checks.disk = DiskHealth(
status=disk_status,
free_gb=round(free_gb, 2),
total_gb=round(total_gb, 2),
)
except Exception:
checks.disk = None
return HealthResponse(
status=overall_status,
timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
version="0.1.0",
checks=checks,
uptime_seconds=round(time.time() - _start_time, 2),
).model_dump()
@router.get(
"/health/db",
response_model=DatabaseHealthResponse,
summary="Database health check",
description="Returns database-specific health status with response time.",
tags=["Health"],
)
async def health_check_db() -> dict[str, Any]:
"""Check database health.
Returns:
DatabaseHealthResponse with status and response time.
"""
import time as time_module
try:
start = time_module.perf_counter()
async with SessionLocal() as session:
await session.execute(text("SELECT 1"))
db_time = (time_module.perf_counter() - start) * 1000
return DatabaseHealthResponse(
status="healthy",
response_time_ms=round(db_time, 2),
).model_dump()
except Exception:
return DatabaseHealthResponse(
status="unhealthy",
response_time_ms=0.0,
).model_dump()
+125
View File
@@ -0,0 +1,125 @@
"""Instance proxy router for forwarding HTTP requests to running containers."""
import logging
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/instances", tags=["instance-proxy"])
async def _proxy_request(
request: Request,
instance_id: uuid.UUID,
path: str,
user_id: uuid.UUID,
session: AsyncSession,
) -> Response:
"""Proxy an HTTP request to a running instance."""
instance = await session.get(ToolInstance, instance_id)
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
# Verify ownership
if instance.owner_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="not authorized to access this instance",
)
if instance.status != "running" or not instance.container_name:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="instance is not running",
)
# Get the tool type to find the internal port
tool_type = await session.get(ToolType, instance.tool_type_id)
internal_port = tool_type.default_port if tool_type and tool_type.default_port else instance.port
# Build target URL using internal port
target_url = f"http://{instance.container_name}:{internal_port}"
if path:
target_url += f"/{path}"
# Get query string
query_string = str(request.query_params)
if query_string:
target_url += f"?{query_string}"
# Forward headers (excluding host and cookies)
headers: dict[str, str] = {}
for key, value in request.headers.items():
if key.lower() not in ("host", "cookie", "content-length"):
headers[key] = value
# Forward the request
try:
async with httpx.AsyncClient() as client:
body = await request.body()
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
follow_redirects=False,
timeout=30.0,
)
except Exception as exc:
logger.error("Proxy error to %s: %s", target_url, exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"failed to reach instance: {exc}",
)
# Build response
response_headers = dict(response.headers)
# Remove hop-by-hop headers
for header in ("content-encoding", "transfer-encoding", "connection"):
response_headers.pop(header, None)
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
)
@router.get("/{instance_id}/proxy/{path:path}")
@router.post("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.put("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.delete("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.patch("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.head("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.options("/{instance_id}/proxy/{path:path}", include_in_schema=False)
async def proxy_to_instance(
request: Request,
instance_id: uuid.UUID,
path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Proxy requests to a running tool instance.
Args:
request: The incoming HTTP request.
instance_id: UUID of the instance.
path: The path to proxy to the instance.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Response from the proxied instance.
"""
return await _proxy_request(request, instance_id, path, user_id, session)
+112 -6
View File
@@ -17,6 +17,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -47,12 +48,28 @@ class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=ProjectResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new project",
description="Create a new project for the authenticated user.",
)
async def create_project(
data: ProjectCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Create a new project.
Args:
data: Project creation data including name and optional description.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created project.
"""
user = await _get_user(session, user_id)
project = Project(
name=data.name,
@@ -66,22 +83,51 @@ async def create_project(
return project
@router.get("", response_model=list[ProjectResponse])
@router.get(
"",
response_model=list[ProjectResponse],
summary="List all projects",
description="Retrieve all projects owned by the authenticated user.",
)
async def list_projects(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[Project]:
"""List all projects for the authenticated user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of projects owned by the user.
"""
user = await _get_user(session, user_id)
result = await session.execute(select(Project).where(Project.owner_id == user.id))
return list(result.scalars().all())
@router.get("/{project_id}", response_model=ProjectResponse)
@router.get(
"/{project_id}",
response_model=ProjectResponse,
summary="Get a project",
description="Retrieve a specific project by ID.",
)
async def get_project(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Get a specific project by ID.
Args:
project_id: UUID of the project to retrieve.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The requested project.
"""
await _get_user(session, user_id)
return await _get_owned_project(project_id, user_id, session)
@@ -91,6 +137,19 @@ async def _get_owned_project(
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
@@ -99,13 +158,29 @@ async def _get_owned_project(
return project
@router.patch("/{project_id}", response_model=ProjectResponse)
@router.patch(
"/{project_id}",
response_model=ProjectResponse,
summary="Update a project",
description="Update a project's name or description.",
)
async def update_project(
project_id: uuid.UUID,
data: ProjectUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Update a project.
Args:
project_id: UUID of the project to update.
data: Project update data with optional name and description.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated project.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
@@ -119,12 +194,27 @@ async def update_project(
return project
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{project_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a project",
description="Delete a project and all its associated repositories.",
)
async def delete_project(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Delete a project and all its repositories.
Args:
project_id: UUID of the project to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Empty response with 204 status code.
"""
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
@@ -141,13 +231,29 @@ async def delete_project(
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.patch("/{project_id}/default-ssh-key", response_model=ProjectResponse)
@router.patch(
"/{project_id}/default-ssh-key",
response_model=ProjectResponse,
summary="Set default SSH key",
description="Set the default SSH key for a project.",
)
async def set_default_ssh_key(
project_id: uuid.UUID,
data: SetDefaultSSHKeyRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
"""Set the default SSH key for a project.
Args:
project_id: UUID of the project.
data: Request containing the SSH key ID to set as default.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated project.
"""
user = await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
+54 -3
View File
@@ -18,6 +18,7 @@ router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -38,6 +39,11 @@ def _get_fernet() -> Fernet:
def generate_ssh_key_pair() -> tuple[str, str]:
"""Generate a new Ed25519 SSH key pair.
Returns:
Tuple of (private_key, public_key) as strings.
"""
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
@@ -68,12 +74,28 @@ class SSHKeyResponse(BaseModel):
created_at: datetime
@router.post("", response_model=SSHKeyResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=SSHKeyResponse,
status_code=status.HTTP_201_CREATED,
summary="Create SSH key",
description="Generate a new Ed25519 SSH key pair for the authenticated user.",
)
async def create_ssh_key(
data: SSHKeyCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> SSHKey:
"""Create a new SSH key pair.
Args:
data: SSH key creation data including the key name.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created SSH key with public key exposed.
"""
user = await _get_user(session, user_id)
private_key, public_key = generate_ssh_key_pair()
@@ -92,22 +114,51 @@ async def create_ssh_key(
return ssh_key
@router.get("", response_model=list[SSHKeyResponse])
@router.get(
"",
response_model=list[SSHKeyResponse],
summary="List SSH keys",
description="List all SSH keys for the authenticated user.",
)
async def list_ssh_keys(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[SSHKey]:
"""List all SSH keys for the authenticated user.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of SSH keys owned by the user.
"""
user = await _get_user(session, user_id)
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
return list(result.scalars().all())
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{key_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete SSH key",
description="Delete an SSH key by ID.",
)
async def delete_ssh_key(
key_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete an SSH key.
Args:
key_id: UUID of the SSH key to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
None with 204 status code.
"""
user = await _get_user(session, user_id)
ssh_key = await session.get(SSHKey, key_id)
if ssh_key is None or ssh_key.user_id != user.id:
+124
View File
@@ -0,0 +1,124 @@
"""WebSocket terminal endpoint for tool instances."""
import asyncio
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_db_session
from src.models.tool_instance import ToolInstance
from src.services.terminal_manager import terminal_manager
router = APIRouter()
logger = logging.getLogger(__name__)
@router.websocket(
"/ws/tool-instances/{instance_id}/terminal",
)
async def terminal_websocket(
websocket: WebSocket,
instance_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
"""WebSocket endpoint for terminal access to a tool instance.
Provides an interactive terminal session inside a running tool instance container.
Args:
websocket: The WebSocket connection.
instance_id: UUID string of the tool instance.
db_session: Database session.
Returns:
None. Communicates via WebSocket messages.
"""
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
await websocket.accept()
try:
# Parse instance_id
instance_uuid = uuid.UUID(instance_id)
except ValueError:
logger.error("Invalid instance ID: %s", instance_id)
await websocket.close(code=4001, reason="Invalid instance ID")
return
# Authenticate user from session cookie
user_id = await _get_user_from_websocket(websocket, db_session)
if user_id is None:
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
await websocket.close(code=4003, reason="Unauthorized")
return
# Get instance and verify ownership
instance = await db_session.get(ToolInstance, instance_uuid)
if instance is None:
logger.warning("Instance %s not found", instance_id)
await websocket.close(code=4004, reason="Instance not found")
return
if instance.owner_id != user_id:
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
await websocket.close(code=4003, reason="Forbidden")
return
if instance.status != "running" or not instance.container_id:
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
await websocket.close(code=4004, reason="Instance not running")
return
logger.info("Creating terminal session for instance %s (container_id=%s)", instance_id, instance.container_id)
# Create terminal session
try:
session = await terminal_manager.create_session(
instance_uuid,
instance.container_id,
websocket,
)
logger.info("Terminal session created successfully for instance %s", instance_id)
# Send connected status
await websocket.send_json({"type": "status", "status": "connected"})
# Keep connection alive until session ends
# The terminal_manager handles I/O loops, we just wait here
while session.is_alive() and not session._closed:
await asyncio.sleep(0.5)
except Exception as exc:
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
await websocket.close(code=4000, reason=f"Error: {exc}")
finally:
# Cleanup will be handled by the session manager
pass
async def _get_user_from_websocket(
websocket: WebSocket,
db_session: AsyncSession,
) -> uuid.UUID | None:
"""Extract and validate user ID from session cookie in WebSocket.
Args:
websocket: The WebSocket connection.
db_session: Database session.
Returns:
The user's UUID if authenticated, None otherwise.
"""
from src.auth.session import decode_session_cookie
from src.config import Settings
session_cookie = websocket.cookies.get("session")
if not session_cookie:
return None
settings = Settings()
try:
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
return uuid.UUID(str(payload["user_id"]))
except (ValueError, KeyError):
return None
+322
View File
@@ -0,0 +1,322 @@
"""Tool configuration API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_config import ToolConfig
from src.models.tool_type import ToolType
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
class ToolConfigCreate(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type")
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
key: str = Field(description="Config key name")
value: str = Field(description="Config value")
config_type: str = Field(default="env", description="Type: env or file")
file_path: str | None = Field(default=None, description="File path for file-type configs")
port_override: int | None = Field(default=None, description="Port override (1-65535)")
start_command: str | None = Field(default=None, description="Override container start command")
working_directory: str | None = Field(default=None, description="Working directory inside container")
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
@field_validator("port_override")
@classmethod
def validate_port(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 1 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
if v is None:
return v
if not isinstance(v, list):
raise ValueError("volumes must be a JSON array")
for i, vol in enumerate(v):
if not isinstance(vol, dict):
raise ValueError(f"Volume at index {i} must be an object")
if "source" not in vol:
raise ValueError(f"Volume at index {i} must have 'source' field")
if "target" not in vol:
raise ValueError(f"Volume at index {i} must have 'target' field")
return v
class ToolConfigUpdate(BaseModel):
key: str | None = Field(default=None, description="Config key name")
value: str | None = Field(default=None, description="Config value")
config_type: str | None = Field(default=None, description="Type: env or file")
file_path: str | None = Field(default=None, description="File path for file-type configs")
port_override: int | None = Field(default=None, description="Port override (1-65535)")
start_command: str | None = Field(default=None, description="Override container start command")
working_directory: str | None = Field(default=None, description="Working directory inside container")
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
@field_validator("port_override")
@classmethod
def validate_port(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 1 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
if v is None:
return v
if not isinstance(v, dict):
raise ValueError("environment_variables must be a JSON object")
return v
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
if v is None:
return v
if not isinstance(v, list):
raise ValueError("volumes must be a JSON array")
for i, vol in enumerate(v):
if not isinstance(vol, dict):
raise ValueError(f"Volume at index {i} must be an object")
if "source" not in vol:
raise ValueError(f"Volume at index {i} must have 'source' field")
if "target" not in vol:
raise ValueError(f"Volume at index {i} must have 'target' field")
return v
class ToolConfigResponse(BaseModel):
id: str
tool_type_id: str
project_id: str | None
key: str
value: str
config_type: str
file_path: str | None
port_override: int | None
start_command: str | None
working_directory: str | None
environment_variables: dict | None
volumes: list[dict] | None
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
async def list_configs(
tool_type_id: str | None = None,
project_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list:
"""List tool configs for the current user."""
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
if tool_type_id:
query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id))
if project_id:
query = query.where(ToolConfig.project_id == uuid.UUID(project_id))
else:
# If no project specified, get only global configs (project_id is None)
query = query.where(ToolConfig.project_id.is_(None))
result = await session.execute(query)
configs = result.scalars().all()
return [
{
"id": str(c.id),
"tool_type_id": str(c.tool_type_id),
"project_id": str(c.project_id) if c.project_id else None,
"key": c.key,
"value": c.value,
"config_type": c.config_type,
"file_path": c.file_path,
"port_override": c.port_override,
"start_command": c.start_command,
"working_directory": c.working_directory,
"environment_variables": c.environment_variables,
"volumes": c.volumes,
}
for c in configs
]
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
async def create_config(
data: ToolConfigCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a tool config."""
# Verify tool type exists
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
# Check for existing config with same key
query = select(ToolConfig).where(
ToolConfig.user_id == user_id,
ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id),
ToolConfig.key == data.key,
)
if data.project_id:
query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id))
else:
query = query.where(ToolConfig.project_id.is_(None))
existing = await session.scalar(query)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config with key '{data.key}' already exists"
)
config = ToolConfig(
user_id=user_id,
tool_type_id=uuid.UUID(data.tool_type_id),
project_id=uuid.UUID(data.project_id) if data.project_id else None,
key=data.key,
value=data.value,
config_type=data.config_type,
file_path=data.file_path,
port_override=data.port_override,
start_command=data.start_command,
working_directory=data.working_directory,
environment_variables=data.environment_variables,
volumes=data.volumes,
)
session.add(config)
await session.commit()
await session.refresh(config)
return {
"id": str(config.id),
"tool_type_id": str(config.tool_type_id),
"project_id": str(config.project_id) if config.project_id else None,
"key": config.key,
"value": config.value,
"config_type": config.config_type,
"file_path": config.file_path,
"port_override": config.port_override,
"start_command": config.start_command,
"working_directory": config.working_directory,
"environment_variables": config.environment_variables,
"volumes": config.volumes,
}
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
async def update_config(
config_id: uuid.UUID,
data: ToolConfigUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a tool config."""
config = await session.get(ToolConfig, config_id)
if config is None or config.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
if data.key is not None:
config.key = data.key
if data.value is not None:
config.value = data.value
if data.config_type is not None:
config.config_type = data.config_type
if data.file_path is not None:
config.file_path = data.file_path
if data.port_override is not None:
config.port_override = data.port_override
if data.start_command is not None:
config.start_command = data.start_command
if data.working_directory is not None:
config.working_directory = data.working_directory
if data.environment_variables is not None:
config.environment_variables = data.environment_variables
if data.volumes is not None:
config.volumes = data.volumes
await session.commit()
await session.refresh(config)
return {
"id": str(config.id),
"tool_type_id": str(config.tool_type_id),
"project_id": str(config.project_id) if config.project_id else None,
"key": config.key,
"value": config.value,
"config_type": config.config_type,
"file_path": config.file_path,
"port_override": config.port_override,
"start_command": config.start_command,
"working_directory": config.working_directory,
"environment_variables": config.environment_variables,
"volumes": config.volumes,
}
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
async def get_default_configs(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get suggested default configs for a tool type."""
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
# Return suggested defaults based on required_variables
defaults = []
for var in tool_type.required_variables:
defaults.append({
"key": var,
"value": "",
"config_type": "env",
"description": f"Required variable: {var}",
})
return {
"tool_type_id": tool_type_id,
"suggested_configs": defaults,
}
@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.")
async def delete_config(
config_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool config."""
config = await session.get(ToolConfig, config_id)
if config is None or config.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
await session.delete(config)
await session.commit()
File diff suppressed because it is too large Load Diff
+412 -33
View File
@@ -3,7 +3,7 @@ from datetime import datetime
import yaml
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/tool-types", tags=["tool-types"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -22,6 +23,11 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
async def _require_admin(user: User) -> None:
"""Check if user has admin privileges.
For now, all authenticated users can manage tool types.
In production, this should check user.role or similar.
"""
# For now, all authenticated users can manage tool types
# In production, check user.role or similar
pass
@@ -31,12 +37,33 @@ class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
compose_template: str
default_port: int
definition_type: str = "compose"
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
required_variables: list[str] = []
category: str = "other"
interfaces: list[str] = ["web"]
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str) -> str:
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
try:
parsed = yaml.safe_load(v)
except yaml.YAMLError as e:
@@ -53,18 +80,79 @@ class ToolTypeCreate(BaseModel):
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@field_validator("default_port")
@classmethod
def validate_default_port(cls, v: int, info) -> int:
if v <= 0 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
# Get compose_template from the model data
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
try:
parsed = yaml.safe_load(template)
except yaml.YAMLError:
return v
# Check if the port is exposed in any service
port_str = str(v)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_name, service_config in parsed["services"].items():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
# Format: "8443:8443" or "8443"
if port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == v:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
return v
@field_validator("required_variables")
@classmethod
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
# Get compose_template from the model data
data = info.data
if "compose_template" not in data:
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
template = data["compose_template"]
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
@@ -72,19 +160,48 @@ class ToolTypeCreate(BaseModel):
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if self.definition_type == "compose" and self.compose_template is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
return self
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
required_variables: list[str] | None = None
category: str | None = None
interfaces: list[str] | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None) -> str | None:
def validate_compose_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "compose":
return v
try:
parsed = yaml.safe_load(v)
except yaml.YAMLError as e:
@@ -101,6 +218,22 @@ class ToolTypeUpdate(BaseModel):
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "dockerfile":
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -109,7 +242,14 @@ class ToolTypeResponse(BaseModel):
name: str
display_name: str
description: str | None
compose_template: str
category: str
interfaces: list[str]
default_port: int
definition_type: str
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
required_variables: list[str]
is_builtin: bool
created_by_id: uuid.UUID | None
@@ -117,12 +257,28 @@ class ToolTypeResponse(BaseModel):
updated_at: datetime
@router.post("", response_model=ToolTypeResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"",
response_model=ToolTypeResponse,
status_code=status.HTTP_201_CREATED,
summary="Create tool type",
description="Create a new custom tool type with a Docker Compose template.",
)
async def create_tool_type(
data: ToolTypeCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Create a new tool type.
Args:
data: Tool type creation data including name, display name, and compose template.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The newly created tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
@@ -135,8 +291,15 @@ async def create_tool_type(
name=data.name,
display_name=data.display_name,
description=data.description,
default_port=data.default_port,
definition_type=data.definition_type,
compose_template=data.compose_template,
dockerfile_template=data.dockerfile_template,
build_context=data.build_context,
readiness_probe=data.readiness_probe,
required_variables=data.required_variables,
category=data.category,
interfaces=data.interfaces,
is_builtin=False,
created_by_id=user.id,
)
@@ -146,22 +309,51 @@ async def create_tool_type(
return tool_type
@router.get("", response_model=list[ToolTypeResponse])
@router.get(
"",
response_model=list[ToolTypeResponse],
summary="List tool types",
description="List all available tool types including built-in and custom ones.",
)
async def list_tool_types(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[ToolType]:
"""List all tool types.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
List of all tool types ordered by name.
"""
await _get_user(session, user_id)
result = await session.execute(select(ToolType).order_by(ToolType.name))
return list(result.scalars().all())
@router.get("/{tool_type_id}", response_model=ToolTypeResponse)
@router.get(
"/{tool_type_id}",
response_model=ToolTypeResponse,
summary="Get tool type",
description="Get a specific tool type by ID.",
)
async def get_tool_type(
tool_type_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Get a specific tool type by ID.
Args:
tool_type_id: UUID of the tool type to retrieve.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The requested tool type.
"""
await _get_user(session, user_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
@@ -169,13 +361,29 @@ async def get_tool_type(
return tool_type
@router.put("/{tool_type_id}", response_model=ToolTypeResponse)
@router.put(
"/{tool_type_id}",
response_model=ToolTypeResponse,
summary="Update tool type",
description="Update a custom tool type. Built-in tool types cannot be modified.",
)
async def update_tool_type(
tool_type_id: uuid.UUID,
data: ToolTypeUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> ToolType:
"""Update a tool type.
Args:
tool_type_id: UUID of the tool type to update.
data: Tool type update data with optional fields.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated tool type.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
@@ -188,26 +396,68 @@ async def update_tool_type(
update_data = data.model_dump(exclude_unset=True)
# Validate required variables if both are being updated
if "required_variables" in update_data and "compose_template" in update_data:
template = update_data["compose_template"]
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
elif "required_variables" in update_data:
# Only updating variables, check against existing template
template = tool_type.compose_template
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
# Validate port if being updated
if "default_port" in update_data:
new_port = update_data["default_port"]
if new_port <= 0 or new_port > 65535:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Port must be between 1 and 65535"
)
# Only validate port exposure for compose definitions
definition_type = update_data.get("definition_type", tool_type.definition_type)
if definition_type == "compose":
template = update_data.get("compose_template", tool_type.compose_template)
if template:
try:
parsed = yaml.safe_load(template)
except yaml.YAMLError:
parsed = None
if parsed and isinstance(parsed, dict) and "services" in parsed:
port_str = str(new_port)
port_exposed = False
for service_config in parsed["services"].values():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str) and port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == new_port:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Port {new_port} is not exposed in the compose template"
)
# Validate required variables for compose definitions
definition_type = update_data.get("definition_type", tool_type.definition_type)
if definition_type == "compose":
if "required_variables" in update_data and "compose_template" in update_data:
template = update_data["compose_template"]
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
elif "required_variables" in update_data:
template = tool_type.compose_template
if template:
for var in update_data["required_variables"]:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
for field, value in update_data.items():
setattr(tool_type, field, value)
@@ -217,12 +467,141 @@ async def update_tool_type(
return tool_type
@router.delete("/{tool_type_id}", status_code=status.HTTP_204_NO_CONTENT)
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
@router.post(
"/validate",
summary="Validate tool type template",
description="Validate a compose template or dockerfile syntax before creating a tool type.",
)
async def validate_tool_type_template(
data: ToolTypeValidateRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Validate a tool type template syntax.
Args:
data: Validation request with definition type and template.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Validation result with success status and any errors.
"""
await _get_user(session, user_id)
errors = []
if data.definition_type == "compose":
if not data.compose_template:
errors.append("Compose template is required")
else:
try:
parsed = yaml.safe_load(data.compose_template)
if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed:
errors.append("Compose template must contain 'services' key")
elif not parsed["services"]:
errors.append("Compose template must define at least one service")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML: {e}")
elif data.definition_type == "dockerfile":
if not data.dockerfile_template:
errors.append("Dockerfile template is required")
elif not data.dockerfile_template.strip().startswith("FROM"):
errors.append("Dockerfile must start with a FROM instruction")
else:
errors.append("definition_type must be 'compose' or 'dockerfile'")
return {
"valid": len(errors) == 0,
"errors": errors,
}
@router.get(
"/{tool_type_id}/validate",
summary="Validate tool type",
description="Validate the compose template or dockerfile syntax of a tool type.",
)
async def validate_tool_type(
tool_type_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Validate a tool type's template syntax.
Args:
tool_type_id: UUID of the tool type to validate.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Validation result with success status and any errors.
"""
await _get_user(session, user_id)
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
errors = []
if tool_type.definition_type == "compose":
if not tool_type.compose_template:
errors.append("Compose template is empty")
else:
try:
parsed = yaml.safe_load(tool_type.compose_template)
if not isinstance(parsed, dict):
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed:
errors.append("Compose template must contain 'services' key")
elif not parsed["services"]:
errors.append("Compose template must define at least one service")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML: {e}")
elif tool_type.definition_type == "dockerfile":
if not tool_type.dockerfile_template:
errors.append("Dockerfile template is empty")
elif not tool_type.dockerfile_template.strip().startswith("FROM"):
errors.append("Dockerfile must start with a FROM instruction")
return {
"valid": len(errors) == 0,
"errors": errors,
}
@router.delete(
"/{tool_type_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete tool type",
description="Delete a custom tool type. Built-in tool types cannot be deleted.",
)
async def delete_tool_type(
tool_type_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool type.
Args:
tool_type_id: UUID of the tool type to delete.
user_id: ID of the authenticated user.
session: Database session.
Returns:
None with 204 status code.
"""
user = await _get_user(session, user_id)
await _require_admin(user)
+51 -4
View File
@@ -1,6 +1,9 @@
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,6 +16,7 @@ router = APIRouter(prefix="/users/me", tags=["user-config"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -20,6 +24,15 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
"""Get or create user config record.
Args:
session: Database session.
user_id: UUID of the user.
Returns:
The user's config, creating a new one if it doesn't exist.
"""
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
config = result.scalar_one_or_none()
if config is None:
@@ -37,6 +50,7 @@ class UserConfigResponse(BaseModel):
theme: str = "system"
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
class UserConfigUpdate(BaseModel):
@@ -44,31 +58,64 @@ class UserConfigUpdate(BaseModel):
theme: str | None = None
git_user_name: str | None = None
git_user_email: str | None = None
last_session_id: str | None = None
@router.get("/config", response_model=UserConfigResponse)
@router.get(
"/config",
response_model=UserConfigResponse,
summary="Get user config",
description="Get the current user's configuration settings.",
)
async def get_user_config(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Get the current user's configuration.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
The user's configuration settings.
"""
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
return UserConfigResponse.model_validate(config.config)
@router.patch("/config", response_model=UserConfigResponse)
@router.patch(
"/config",
response_model=UserConfigResponse,
summary="Update user config",
description="Update the current user's configuration settings.",
)
async def update_user_config(
data: UserConfigUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> UserConfigResponse:
"""Update the current user's configuration.
Args:
data: Configuration update data with optional fields.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user configuration.
"""
_user = await _get_user(session, user_id)
config = await _get_or_create_config(session, user_id)
# Merge updates
update_data = data.model_dump(exclude_unset=True, exclude_none=True)
config.config.update(update_data)
update_data = data.model_dump(exclude_unset=True)
logger.info("Updating user config for user %s: %s", user_id, update_data)
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
config.config = {**config.config, **update_data}
await session.commit()
await session.refresh(config)
logger.info("Updated config: %s", config.config)
return UserConfigResponse.model_validate(config.config)
+48 -3
View File
@@ -17,6 +17,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
@@ -37,20 +38,49 @@ class UserProfileUpdate(BaseModel):
email: str | None = None
@router.get("/me", response_model=UserProfileResponse)
@router.get(
"/me",
response_model=UserProfileResponse,
summary="Get current user profile",
description="Retrieve the profile of the currently authenticated user.",
)
async def get_profile(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Get the current user's profile.
Args:
user_id: ID of the authenticated user.
session: Database session.
Returns:
The user's profile information.
"""
return await _get_user(session, user_id)
@router.put("/me", response_model=UserProfileResponse)
@router.put(
"/me",
response_model=UserProfileResponse,
summary="Update user profile",
description="Update the current user's profile information.",
)
async def update_profile(
data: UserProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Update the current user's profile.
Args:
data: Profile update data with optional name and email.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user profile.
"""
user = await _get_user(session, user_id)
if data.name is not None:
@@ -68,12 +98,27 @@ async def update_profile(
return user
@router.post("/me/avatar", response_model=UserProfileResponse)
@router.post(
"/me/avatar",
response_model=UserProfileResponse,
summary="Upload avatar",
description="Upload a profile avatar image (PNG or JPG, max 2MB).",
)
async def upload_avatar(
file: UploadFile,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> User:
"""Upload a profile avatar image.
Args:
file: The image file to upload (PNG or JPG, max 2MB).
user_id: ID of the authenticated user.
session: Database session.
Returns:
The updated user profile with new avatar URL.
"""
user = await _get_user(session, user_id)
if file.content_type not in ALLOWED_CONTENT_TYPES:
+5
View File
@@ -49,6 +49,11 @@ class Settings(BaseSettings):
# Repository storage
repo_base_path: str = "/data/repos"
# Tool instance storage
instance_base_path: str = "/data/instances"
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
+127 -13
View File
@@ -1,16 +1,24 @@
import json
import logging
import os
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select, text
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
from src.api.git_repositories import router as git_repositories_router
from src.api.health import router as health_router
from src.api.projects import router as projects_router
from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.instance_proxy import router as instance_proxy_router
from src.api.config_folders import router as config_folders_router
from src.api.tool_configs import router as tool_configs_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router
from src.api.tool_types import router as tool_types_router
@@ -34,9 +42,15 @@ settings = Settings()
app = FastAPI(title="Headquarter API")
# Configure CORS - must be before other middleware
# Build allowed origins list including web and api domains
cors_origins = [settings.web_base_url]
if settings.api_base_url != settings.web_base_url:
cors_origins.append(settings.api_base_url)
logger.info("CORS configured with origins: %s", cors_origins)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.web_base_url],
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -46,6 +60,49 @@ app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(ExceptionLoggingMiddleware)
def _sanitize_validation_errors(errors):
"""Convert validation errors to JSON-safe format."""
sanitized = []
for error in errors:
safe_error = {
"type": error.get("type"),
"loc": error.get("loc"),
"msg": error.get("msg"),
"input": str(error.get("input")) if error.get("input") is not None else None,
}
# Convert ctx to safe format
ctx = error.get("ctx")
if ctx:
safe_ctx = {}
for key, value in ctx.items():
if isinstance(value, Exception):
safe_ctx[key] = str(value)
elif isinstance(value, (str, int, float, bool, type(None))):
safe_ctx[key] = value
else:
safe_ctx[key] = str(value)
safe_error["ctx"] = safe_ctx
sanitized.append(safe_error)
return sanitized
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Log validation errors and return detailed response."""
errors = exc.errors()
logger.warning(
"Validation error for %s %s: %s",
request.method,
request.url.path,
errors,
)
safe_errors = _sanitize_validation_errors(errors)
return JSONResponse(
status_code=422,
content={"detail": safe_errors},
)
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
try:
@@ -79,6 +136,8 @@ async def seed_builtin_tool_types():
"name": "code-server",
"display_name": "VS Code Server",
"description": "VS Code running in the browser via code-server",
"category": "editor",
"interfaces": ["web"],
"compose_template": """version: "3.8"
services:
code-server:
@@ -93,12 +152,16 @@ services:
ports:
- "8443:8443"
restart: unless-stopped""",
"default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "jupyter-notebook",
"display_name": "Jupyter Notebook",
"description": "Jupyter Lab for interactive development",
"category": "notebook",
"interfaces": ["web"],
"default_port": 8888,
"compose_template": """version: "3.8"
services:
jupyter:
@@ -113,6 +176,47 @@ services:
restart: unless-stopped""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
{
"name": "opencode",
"display_name": "OpenCode",
"description": "AI coding assistant - run opencode in terminal",
"category": "ai-assistant",
"interfaces": ["terminal"],
"default_port": 3000,
"compose_template": """version: "3.8"
services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /workspace
environment:
- HOME=/tmp
volumes:
- {{REPO_PATH}}:/workspace
- opencode_home:/tmp
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
echo 'cd /workspace' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
cd /workspace &&
exec tail -f /dev/null"
stdin_open: true
tty: true
restart: unless-stopped
volumes:
opencode_home:""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
for tool_data in builtin_types:
@@ -122,11 +226,27 @@ services:
name=tool_data["name"],
display_name=tool_data["display_name"],
description=tool_data["description"],
category=tool_data["category"],
interfaces=tool_data["interfaces"],
definition_type="compose",
compose_template=tool_data["compose_template"],
required_variables=tool_data["required_variables"],
default_port=tool_data.get("default_port"),
is_builtin=True,
)
session.add(tool_type)
logger.info("Created built-in tool type: %s", tool_data["name"])
else:
# Update existing built-in tool types to reflect code changes
existing.display_name = tool_data["display_name"]
existing.description = tool_data["description"]
existing.category = tool_data["category"]
existing.interfaces = tool_data["interfaces"]
existing.definition_type = "compose"
existing.compose_template = tool_data["compose_template"]
existing.required_variables = tool_data["required_variables"]
existing.default_port = tool_data.get("default_port")
logger.info("Updated built-in tool type: %s", tool_data["name"])
await session.commit()
logger.info("Built-in tool types seeded successfully.")
@@ -147,17 +267,7 @@ async def on_startup():
await seed_builtin_tool_types()
logger.info("Startup complete.")
@app.get("/health")
async def health_check():
try:
from sqlalchemy import text
async with SessionLocal() as session:
await session.execute(text("SELECT 1"))
return {"status": "healthy", "database": "connected"}
except Exception as exc:
logger.error("Health check failed: %s", exc)
return {"status": "unhealthy", "database": "disconnected", "error": str(exc)}
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(dashboard_router)
app.include_router(projects_router)
@@ -166,6 +276,10 @@ app.include_router(ssh_keys_router)
app.include_router(git_repositories_router)
app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(config_folders_router)
app.include_router(tool_instances_router)
app.include_router(tool_configs_router)
app.include_router(sessions_router)
app.include_router(instance_proxy_router)
app.include_router(terminal_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+18 -1
View File
@@ -1,4 +1,8 @@
from src.models.base import Base
from src.models.config_folder import ConfigFolder
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
@@ -7,4 +11,17 @@ from src.models.tool_type import ToolType
from src.models.user import User
from src.models.user_config import UserConfig
__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
__all__ = [
"Base",
"ConfigFolder",
"ConfigInclude",
"ConfigMount",
"ConfigProfile",
"GitRepository",
"Project",
"SSHKey",
"ToolInstance",
"ToolType",
"User",
"UserConfig",
]
+33
View File
@@ -0,0 +1,33 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.user import User
class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_folders"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
files: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"relative/path": "content", ...}
project_overrides: Mapped[dict | None] = mapped_column(
JSON, default=dict, nullable=True
) # {"project_id": {"mount_path": "...", "files": {...}}}
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
user: Mapped["User"] = relationship()
+36
View File
@@ -0,0 +1,36 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_profile import ConfigProfile
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_includes"
__table_args__ = (
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
)
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
included_profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="includes",
)
included_profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[included_profile_id],
)
+35
View File
@@ -0,0 +1,35 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_profile import ConfigProfile
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_mounts"
profile_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
)
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
content: Mapped[str | None] = mapped_column(Text, nullable=True)
source_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
)
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
profile: Mapped["ConfigProfile"] = relationship(
"ConfigProfile",
foreign_keys=[profile_id],
back_populates="mounts",
)
source_profile: Mapped["ConfigProfile | None"] = relationship(
"ConfigProfile",
foreign_keys=[source_profile_id],
)
+39
View File
@@ -0,0 +1,39 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.user import User
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_profiles"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
user: Mapped["User"] = relationship()
includes: Mapped[list["ConfigInclude"]] = relationship(
"ConfigInclude",
foreign_keys="ConfigInclude.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigInclude.order_index",
)
mounts: Mapped[list["ConfigMount"]] = relationship(
"ConfigMount",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigMount.order_index",
)
+48
View File
@@ -0,0 +1,48 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "tool_configs"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id"), nullable=False
)
tool_type_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("tool_types.id"), nullable=False
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("projects.id"), nullable=True
)
key: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
config_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="env"
) # "env" or "file"
file_path: Mapped[str | None] = mapped_column(
String(1024), nullable=True
) # Only for file type
port_override: Mapped[int | None] = mapped_column(nullable=True)
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
environment_variables: Mapped[dict | None] = mapped_column(
JSON, default=dict, nullable=True
)
volumes: Mapped[list[dict] | None] = mapped_column(
JSON, default=list, nullable=True
)
user: Mapped["User"] = relationship()
tool_type: Mapped["ToolType"] = relationship()
project: Mapped["Project | None"] = relationship()
+14
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.tool_type import ToolType
@@ -38,12 +39,21 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
container_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
container_name: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
compose_path: Mapped[str | None] = mapped_column(
String(1024), nullable=True
)
url: Mapped[str | None] = mapped_column(
String(1024), nullable=True
)
public_url: Mapped[str | None] = mapped_column(
String(1024), nullable=True
)
tunnel_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
port: Mapped[int | None] = mapped_column(
Integer, nullable=True
)
@@ -53,8 +63,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
last_stopped_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
)
tool_type: Mapped["ToolType"] = relationship()
repository: Mapped["GitRepository"] = relationship()
project: Mapped["Project"] = relationship()
owner: Mapped["User"] = relationship()
selected_profile: Mapped["ConfigProfile | None"] = relationship()
+12 -1
View File
@@ -17,7 +17,18 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
compose_template: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
default_port: Mapped[int] = mapped_column(nullable=False)
definition_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="compose"
) # "compose" or "dockerfile"
compose_template: Mapped[str | None] = mapped_column(Text, nullable=True)
dockerfile_template: Mapped[str | None] = mapped_column(Text, nullable=True)
build_context: Mapped[dict | None] = mapped_column(
JSON, default=dict, nullable=True
)
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
+20
View File
@@ -18,3 +18,23 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
user: Mapped["User"] = relationship(back_populates="user_config")
@property
def default_profile_id(self) -> uuid.UUID | None:
profile_id = self.config.get("default_profile_id")
return uuid.UUID(profile_id) if profile_id else None
@default_profile_id.setter
def default_profile_id(self, value: uuid.UUID | None) -> None:
if value is not None:
self.config["default_profile_id"] = str(value)
elif "default_profile_id" in self.config:
del self.config["default_profile_id"]
@property
def default_profiles(self) -> dict[str, str]:
return self.config.get("default_profiles", {})
@default_profiles.setter
def default_profiles(self, value: dict[str, str]) -> None:
self.config["default_profiles"] = value
+288 -3
View File
@@ -23,16 +23,19 @@ def render_compose_template(template: str, variables: dict[str, Any]) -> str:
return result
def ensure_instance_directory(instance_id: str, base_path: str = "data/instances") -> str:
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
"""Create and return the instance directory path.
Args:
instance_id: Unique instance identifier
base_path: Base directory for all instances
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
Returns:
Absolute path to instance directory
"""
if base_path is None:
from src.config import Settings
base_path = Settings().instance_base_path
instance_dir = Path(base_path) / instance_id
instance_dir.mkdir(parents=True, exist_ok=True)
return str(instance_dir.absolute())
@@ -53,8 +56,97 @@ def write_compose_file(instance_dir: str, content: str) -> str:
return str(compose_path)
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
"""Write environment variables to a .env file.
Args:
instance_dir: Path to instance directory
env_vars: Dictionary of env var names to values
Returns:
Path to the env file
"""
env_path = Path(instance_dir) / ".env"
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
env_path.write_text("\n".join(lines) + "\n")
return str(env_path)
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
"""Write config files to the instance directory.
Args:
instance_dir: Path to instance directory
files: Dictionary of file paths (relative to instance dir) to content
"""
instance_path = Path(instance_dir)
for file_path, content in files.items():
# Ensure the path is within the instance directory (security)
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
raise ValueError(f"File path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
"""Write config folder files to the instance directory and return volume mounts.
Args:
instance_dir: Path to instance directory
folders: List of ConfigFolder objects
project_id: Optional project ID for applying overrides
Returns:
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
"""
instance_path = Path(instance_dir)
volume_mounts = []
for folder in folders:
# Determine mount path (with project override if applicable)
mount_path = folder.mount_path
files = folder.files.copy()
if project_id and folder.project_overrides:
override = folder.project_overrides.get(str(project_id))
if override:
if override.get("mount_path"):
mount_path = override["mount_path"]
if override.get("files"):
files.update(override["files"])
# Write files to instance directory
folder_dir = instance_path / "volumes" / folder.name
folder_dir.mkdir(parents=True, exist_ok=True)
for file_path, content in files.items():
# Security: ensure path doesn't escape folder_dir
full_path = folder_dir / file_path
try:
full_path.resolve().relative_to(folder_dir.resolve())
except ValueError:
logger.warning("Config folder file path escapes directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Add volume mount
volume_mounts.append({
"source": str(folder_dir),
"target": mount_path,
"type": "bind",
})
return volume_mounts
def execute_compose_command(
compose_path: str, action: str, timeout: int = 60
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
) -> tuple[int, str, str]:
"""Execute a docker compose command.
@@ -62,6 +154,7 @@ def execute_compose_command(
compose_path: Path to docker-compose.yml
action: The compose action (up, down, start, stop, restart)
timeout: Command timeout in seconds
env_file: Optional path to .env file for environment variables
Returns:
Tuple of (returncode, stdout, stderr)
@@ -69,6 +162,9 @@ def execute_compose_command(
instance_dir = Path(compose_path).parent
cmd = ["docker", "compose", "-f", compose_path]
if env_file:
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d"])
@@ -110,6 +206,44 @@ def get_container_id(instance_name: str) -> str | None:
return None
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Args:
instance_name: The service name in compose
Returns:
Container name or None if not found
"""
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")[0]
return None
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
"""Connect a Docker container to an existing network.
Args:
container_name: Name or ID of the container
network_name: Name of the Docker network (default: backend)
Returns:
True if successful, False otherwise
"""
result = subprocess.run(
["docker", "network", "connect", network_name, container_name],
capture_output=True,
text=True,
)
return result.returncode == 0
def get_container_status(container_id: str) -> str:
"""Get the status of a Docker container.
@@ -169,3 +303,154 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
return port
raise RuntimeError(f"No free port found in range {start}-{end}")
import subprocess
import time
import re
def start_cloudflared_tunnel(
container_name: str, port: int, timeout: int = 30
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for a container.
Uses 'cloudflared tunnel --url' to create a temporary tunnel
with a random trycloudflare.com URL.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
timeout: Maximum seconds to wait for tunnel URL
Returns:
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
"""
import subprocess
import time
import re
import logging
logger = logging.getLogger(__name__)
# First verify the container is accessible
logger.info("Checking connectivity to %s:%d...", container_name, port)
for attempt in range(10):
check = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
f"http://{container_name}:{port}"],
capture_output=True,
text=True,
timeout=5,
)
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
if check.returncode == 0:
break
time.sleep(1)
else:
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
# Run cloudflared in background, capture output
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
proc = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# Wait for the URL to appear in output
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
start_time = time.time()
url = None
while time.time() - start_time < timeout:
# Read available output
import select
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
if readable:
line = proc.stdout.readline()
if line:
match = url_pattern.search(line)
if match:
url = match.group(0)
break
if not url:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError(
f"Failed to get tunnel URL within {timeout}s. "
f"cloudflared output may contain errors."
)
return {"url": url, "pid": str(proc.pid)}
def stop_cloudflared_tunnel(pid: str) -> None:
"""Stop a cloudflared tunnel process.
Args:
pid: Process ID of the cloudflared tunnel
"""
import os
import signal
try:
os.kill(int(pid), signal.SIGTERM)
except ProcessLookupError:
pass # Already stopped
def recreate_tunnel(
container_name: str, port: int, old_pid: str | None = None
) -> dict[str, str]:
"""Recreate a temporary Cloudflare tunnel.
Stops the old tunnel (if pid provided) and starts a new one.
Args:
container_name: Name of the Docker container to tunnel to
port: Port number the container listens on
old_pid: Optional PID of the old tunnel process to stop
Returns:
Dict with 'url' and 'pid' for the new tunnel
"""
if old_pid:
stop_cloudflared_tunnel(old_pid)
return start_cloudflared_tunnel(container_name, port)
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
"""Check if a tunnel URL is healthy.
Args:
url: The tunnel URL to check
timeout: Request timeout in seconds
Returns:
Dict with 'healthy' (bool) and 'status_code' (int or None)
"""
import subprocess
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", str(timeout), url],
capture_output=True,
text=True,
timeout=timeout + 5,
)
status_code = int(result.stdout.strip())
return {
"healthy": 200 <= status_code < 400,
"status_code": status_code,
}
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
return {
"healthy": False,
"status_code": None,
"error": str(e),
}
+69
View File
@@ -0,0 +1,69 @@
"""Docker build service for building images from Dockerfiles."""
import logging
import subprocess
logger = logging.getLogger(__name__)
def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None) -> tuple[int, str, str]:
"""Build a Docker image from a Dockerfile.
Args:
instance_dir: Directory containing the Dockerfile
dockerfile: Dockerfile content
tag: Image tag to apply
build_context: Optional build context files {path: content}
Returns:
Tuple of (returncode, stdout, stderr)
"""
import os
from pathlib import Path
# Write Dockerfile
dockerfile_path = Path(instance_dir) / "Dockerfile"
dockerfile_path.write_text(dockerfile)
logger.info("Wrote Dockerfile to %s", dockerfile_path)
# Write build context files
if build_context:
for file_path, content in build_context.items():
full_path = Path(instance_dir) / file_path
# Security: ensure path doesn't escape instance_dir
try:
full_path.resolve().relative_to(Path(instance_dir).resolve())
except ValueError:
logger.error("Build context file path escapes instance directory: %s", file_path)
raise ValueError(f"Build context file path '{file_path}' escapes instance directory")
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
logger.info("Wrote build context file: %s", full_path)
# Build image
logger.info("Building Docker image with tag: %s", tag)
cmd = [
"docker", "build",
"-t", tag,
"-f", str(dockerfile_path),
instance_dir,
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300, # 5 minute timeout for builds
)
logger.info("Docker build completed: returncode=%d", result.returncode)
if result.returncode != 0:
logger.error("Docker build failed: %s", result.stderr[:1000])
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired:
logger.error("Docker build timed out after 300 seconds")
return 1, "", "Build timed out after 300 seconds"
except Exception as exc:
logger.exception("Docker build failed: %s", exc)
return 1, "", str(exc)
+66
View File
@@ -0,0 +1,66 @@
"""Readiness probe service for checking if containers are ready."""
import asyncio
import logging
import subprocess
logger = logging.getLogger(__name__)
async def execute_probe(
container_id: str,
command: str,
timeout: int = 30,
interval: int = 2,
) -> tuple[bool, list[str]]:
"""Execute a readiness probe command inside a container.
Args:
container_id: Docker container ID or name
command: Command to execute inside the container
timeout: Maximum total time to wait (seconds)
interval: Time between retries (seconds)
Returns:
Tuple of (success, logs)
"""
logs = []
start_time = asyncio.get_event_loop().time()
attempt = 0
while True:
attempt += 1
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed >= timeout:
logs.append(f"Probe timed out after {timeout}s ({attempt} attempts)")
return False, logs
try:
logger.debug("Probe attempt %d: %s", attempt, command)
# Execute command inside container
result = subprocess.run(
["docker", "exec", container_id, "sh", "-c", command],
capture_output=True,
text=True,
timeout=interval, # Each attempt has its own timeout
)
if result.returncode == 0:
logs.append(f"Attempt {attempt}: Success")
if result.stdout:
logs.append(f"Output: {result.stdout.strip()}")
return True, logs
else:
logs.append(f"Attempt {attempt}: Failed (exit code {result.returncode})")
if result.stderr:
logs.append(f"Stderr: {result.stderr.strip()[:200]}")
except subprocess.TimeoutExpired:
logs.append(f"Attempt {attempt}: Command timed out")
except Exception as exc:
logs.append(f"Attempt {attempt}: Error - {exc}")
# Wait before next attempt
await asyncio.sleep(interval)
+96
View File
@@ -0,0 +1,96 @@
"""Terminal session manager for WebSocket connections."""
import asyncio
import uuid
from typing import Any
from fastapi import WebSocket
from src.services.terminal_session import TerminalSession
class TerminalManager:
"""Manages active terminal sessions."""
def __init__(self) -> None:
self._sessions: dict[str, TerminalSession] = {}
async def create_session(
self,
instance_id: uuid.UUID,
container_id: str,
websocket: WebSocket,
) -> TerminalSession:
"""Create a new terminal session."""
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id)
await session.start()
self._sessions[session_id] = session
# Start background tasks for I/O streaming
asyncio.create_task(self._read_loop(session, websocket))
asyncio.create_task(self._write_loop(session, websocket))
return session
async def _read_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
"""Read output from the container and send to WebSocket."""
try:
while session.is_alive() and not session._closed:
data = await session.read_output()
if data:
await websocket.send_bytes(data)
else:
await asyncio.sleep(0.01)
except Exception:
pass
finally:
await self._cleanup_session(session)
async def _write_loop(self, session: TerminalSession, websocket: WebSocket) -> None:
"""Read input from WebSocket and send to container."""
try:
while session.is_alive() and not session._closed:
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "bytes" in message:
await session.write_input(message["bytes"])
elif "text" in message:
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
if ctrl.get("type") == "resize":
await session.resize(
ctrl.get("cols", 80),
ctrl.get("rows", 24),
)
except json.JSONDecodeError:
pass
else:
await session.write_input(text.encode("utf-8"))
elif message["type"] == "websocket.disconnect":
break
except Exception:
pass
finally:
await self._cleanup_session(session)
async def _cleanup_session(self, session: TerminalSession) -> None:
"""Clean up a session."""
if session.session_id in self._sessions:
del self._sessions[session.session_id]
await session.close()
async def close_all(self) -> None:
"""Close all active sessions."""
sessions = list(self._sessions.values())
self._sessions.clear()
for session in sessions:
await session.close()
# Global terminal manager instance
terminal_manager = TerminalManager()
+117
View File
@@ -0,0 +1,117 @@
"""Terminal session management for tool instances."""
import asyncio
import os
import pty
import select
import struct
import fcntl
import uuid
from typing import Any
class TerminalSession:
"""Manages a single terminal session connected to a docker container."""
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
self.process: asyncio.subprocess.Process | None = None
self._closed = False
self._master_fd: int | None = None
self._slave_fd: int | None = None
async def start(self) -> None:
"""Start the docker exec process with a shell using a PTY."""
# Create a pseudo-terminal on the host
self._master_fd, self._slave_fd = pty.openpty()
# Set the terminal size initially
self._set_terminal_size(80, 24)
# Start docker exec with the slave fd as stdin/stdout/stderr
# Using -it because the slave fd IS a TTY
self.process = await asyncio.create_subprocess_exec(
"docker",
"exec",
"-it",
"-e",
"TERM=xterm",
self.container_id,
"bash",
"-il",
stdin=self._slave_fd,
stdout=self._slave_fd,
stderr=self._slave_fd,
)
# Close slave fd in parent process
os.close(self._slave_fd)
self._slave_fd = None
def _set_terminal_size(self, cols: int, rows: int) -> None:
"""Set the terminal size using TIOCSWINSZ."""
if self._master_fd is None:
return
# TIOCSWINSZ = 0x5414 on Linux
TIOCSWINSZ = 0x5414
size = struct.pack('HHHH', rows, cols, 0, 0)
try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
except (OSError, IOError):
pass
async def read_output(self) -> bytes:
"""Read output from the PTY master."""
if self._master_fd is None or self._closed:
return b""
try:
# Use select to check if data is available
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
if readable:
return os.read(self._master_fd, 4096)
return b""
except (OSError, IOError, ValueError):
return b""
async def write_input(self, data: bytes) -> None:
"""Write input to the PTY master."""
if self._master_fd is None or self._closed:
return
try:
os.write(self._master_fd, data)
except (OSError, IOError):
pass
async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal."""
if self._closed:
return
self._set_terminal_size(cols, rows)
async def close(self) -> None:
"""Close the session and cleanup."""
if self._closed:
return
self._closed = True
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
self._master_fd = None
if self.process is not None:
try:
self.process.kill()
await asyncio.wait_for(self.process.wait(), timeout=2.0)
except (asyncio.TimeoutError, ProcessLookupError):
pass
def is_alive(self) -> bool:
"""Check if the session process is still running."""
if self.process is None:
return False
return self.process.returncode is None
+16 -3
View File
@@ -45,7 +45,10 @@ def get_status(repo_path: str) -> GitStatus:
try:
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
except RuntimeError:
branch = "HEAD"
try:
branch = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
except RuntimeError:
branch = "HEAD"
status = GitStatus(branch=branch)
@@ -118,6 +121,12 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
Raises:
RuntimeError: If branch creation fails
"""
try:
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
except RuntimeError:
_run_git_command(repo_path, "checkout", "--orphan", name)
return
_run_git_command(repo_path, "branch", name, base_branch)
@@ -215,7 +224,8 @@ def pull(repo_path: str, branch: str | None = None) -> None:
"""
args = ["pull"]
if branch:
args.extend(["origin", branch])
args.append("origin")
args.append(branch)
_run_git_command(repo_path, *args)
@@ -279,4 +289,7 @@ def get_current_branch(repo_path: str) -> str:
Returns:
Current branch name
"""
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
try:
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
except RuntimeError:
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
+54 -5
View File
@@ -1,5 +1,6 @@
"""Git file utilities for browsing repository contents."""
import logging
import subprocess
from dataclasses import dataclass
from pathlib import Path
@@ -50,10 +51,31 @@ def _run_git_command(repo_path: str, *args: str) -> str:
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Git command failed: {result.stderr}")
stderr = result.stderr
# Handle "dubious ownership" security error
if "dubious ownership" in stderr.lower():
logger.warning("Git ownership mismatch for %s, adding to safe.directory", repo_path)
# Add this directory to git's safe.directory list
subprocess.run(
["git", "config", "--global", "--add", "safe.directory", repo_path],
capture_output=True,
)
# Retry the command
result = subprocess.run(
["git", *args],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout
stderr = result.stderr
raise RuntimeError(f"Git command failed: {stderr}")
return result.stdout
logger = logging.getLogger(__name__)
def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[FileTreeEntry]:
"""List files and directories in a repository path.
@@ -69,10 +91,20 @@ def list_tree(repo_path: str, branch: str = "main", path: str = "") -> list[File
try:
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
except RuntimeError:
except RuntimeError as e:
logger.warning("git ls-tree failed for %s with branch '%s': %s", repo_path, tree_path, str(e))
# Try with HEAD if branch doesn't exist
tree_path = f"HEAD:{path}" if path else "HEAD"
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
try:
output = _run_git_command(repo_path, "ls-tree", "-l", tree_path)
except RuntimeError as e:
logger.error("git ls-tree failed for %s with HEAD: %s", repo_path, str(e))
# Check if this is an empty repository (no commits yet)
error_msg = str(e).lower()
if "not a valid object name" in error_msg or "does not exist" in error_msg:
# Empty repository - return empty list
return []
raise
entries = []
for line in output.strip().split("\n"):
@@ -248,7 +280,11 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
Tuple of (list of BranchInfo, default branch name)
"""
# Get all branches
output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)")
try:
output = _run_git_command(repo_path, "branch", "-a", "--format=%(refname:short)")
except RuntimeError as e:
logger.error("Failed to list branches for %s: %s", repo_path, str(e))
raise
branches: list[BranchInfo] = []
default_branch = "main"
@@ -310,7 +346,20 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
)
default_branch = branch_name
except RuntimeError:
pass
try:
output = _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD")
branch_name = output.strip()
if branch_name:
branches.append(
BranchInfo(
name=branch_name,
is_default=True,
last_commit=None,
)
)
default_branch = branch_name
except RuntimeError:
pass
return branches, default_branch
+131 -70
View File
@@ -3,6 +3,7 @@
import asyncio
import os
from typing import AsyncGenerator, Generator
from unittest.mock import patch
import pytest
import pytest_asyncio
@@ -11,82 +12,142 @@ from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
# Set test environment BEFORE importing app modules
os.environ["APP_ENV"] = "testing"
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
from src.config import Settings, build_database_url
from src.models.base import Base
from src.main import app
# Unit test fixtures (SQLite in-memory)
@pytest.fixture(scope="session")
def sqlite_engine():
"""Create a SQLite in-memory engine for unit tests."""
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def sqlite_session(sqlite_engine) -> Generator:
"""Provide a SQLite session for unit tests."""
connection = sqlite_engine.connect()
transaction = connection.begin()
session = sessionmaker(bind=connection)()
yield session
session.close()
transaction.rollback()
connection.close()
# Integration test fixtures (PostgreSQL)
TEST_DATABASE_URL = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
@pytest_asyncio.fixture(scope="session")
async def postgres_engine():
"""Create a PostgreSQL engine for integration tests."""
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(postgres_engine) -> AsyncGenerator[AsyncSession, None]:
"""Provide a database session with transaction rollback."""
async with postgres_engine.connect() as connection:
transaction = await connection.begin_nested()
session_factory = async_sessionmaker(
connection, expire_on_commit=False, class_=AsyncSession
)
session = session_factory()
yield session
await session.close()
await transaction.rollback()
from src.auth.dependencies import get_db_session
@pytest.fixture
def test_client() -> Generator[TestClient, None, None]:
"""Provide a FastAPI test client."""
with TestClient(app) as client:
yield client
"""Provide a FastAPI test client with SQLite database."""
# Create a single engine for this test
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
)
# Create tables
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(init_db())
async def override_get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
yield session
# Override the dependency
app.dependency_overrides[get_db_session] = override_get_db_session
# Patch startup events to prevent PostgreSQL connection attempts
with patch("src.main.init_database") as mock_init, \
patch("src.main.seed_builtin_tool_types") as mock_seed:
mock_init.return_value = True
mock_seed.return_value = None
try:
with TestClient(app) as client:
yield client
finally:
# Clean up overrides
app.dependency_overrides.pop(get_db_session, None)
asyncio.run(engine.dispose())
@pytest.fixture(autouse=True)
def configure_test_env(monkeypatch):
"""Configure environment for testing."""
monkeypatch.setenv("DATABASE_URL", TEST_DATABASE_URL)
monkeypatch.setenv("APP_ENV", "testing")
@pytest.fixture
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with a test user."""
import uuid
from src.auth.session import create_session_cookie
from src.models.user import User
user_id = str(uuid.uuid4())
settings = Settings()
# Create user in database using the same engine as test_client
# We need to access the engine from the test_client fixture
# Since we can't easily do that, we'll create the user via API call
# But we need the user to exist before any API calls
# So we need to create the user using the overridden dependency
async def create_test_user():
# Get the override function
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
user = User(
id=uuid.UUID(user_id),
email="test@headquarter.local",
name="Test User",
authentik_id=f"authentik-{user_id}",
avatar_url=None,
)
session.add(user)
await session.commit()
finally:
await gen.aclose()
asyncio.run(create_test_user())
# Create session cookie
session_cookie = create_session_cookie(
settings=settings,
user_id=user_id,
)
# Set cookie on client
test_client.cookies.set("session", session_cookie)
yield test_client
@pytest.fixture
def admin_client(test_client) -> Generator[TestClient, None, None]:
"""Provide an authenticated test client with an admin user."""
import uuid
from src.auth.session import create_session_cookie
from src.models.user import User
user_id = str(uuid.uuid4())
settings = Settings()
async def create_admin_user():
override_fn = app.dependency_overrides.get(get_db_session)
if override_fn:
gen = override_fn()
session = await gen.asend(None)
try:
user = User(
id=uuid.UUID(user_id),
email="admin@headquarter.local",
name="Admin User",
authentik_id=f"authentik-admin-{user_id}",
avatar_url=None,
is_admin=True,
)
session.add(user)
await session.commit()
finally:
await gen.aclose()
asyncio.run(create_admin_user())
# Create session cookie
session_cookie = create_session_cookie(
settings=settings,
user_id=user_id,
)
# Set cookie on client
test_client.cookies.set("session", session_cookie)
yield test_client
@@ -0,0 +1,255 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestConfigFoldersAPI:
"""Integration tests for config folders API."""
def test_list_config_folders_requires_authentication(self, test_client: TestClient) -> None:
"""Test that listing config folders requires authentication."""
response = test_client.get("/config-folders")
assert response.status_code == 401
def test_list_config_folders_returns_user_folders(self, authenticated_client: TestClient) -> None:
"""Test that authenticated users can list their folders."""
response = authenticated_client.get("/config-folders")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
assert "folders" in data
assert isinstance(data["folders"], list)
def test_create_config_folder_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config folder."""
response = authenticated_client.post(
"/config-folders",
json={
"name": "test-folder",
"description": "Test folder",
"mount_path": "/home/user",
"files": {"test.txt": "hello world"},
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "test-folder"
assert data["mount_path"] == "/home/user"
assert data["files"] == {"test.txt": "hello world"}
def test_create_config_folder_duplicate_name(self, authenticated_client: TestClient) -> None:
"""Test that duplicate folder names are rejected."""
# Create first folder
response = authenticated_client.post(
"/config-folders",
json={
"name": "duplicate-folder",
"mount_path": "/home/user",
"files": {},
},
)
assert response.status_code == 201
# Try to create second with same name
response = authenticated_client.post(
"/config-folders",
json={
"name": "duplicate-folder",
"mount_path": "/home/user",
"files": {},
},
)
assert response.status_code == 409
def test_create_config_folder_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
"""Test that folders exceeding 10MB are rejected."""
large_content = "x" * (11 * 1024 * 1024) # 11MB
response = authenticated_client.post(
"/config-folders",
json={
"name": "large-folder",
"mount_path": "/home/user",
"files": {"large.txt": large_content},
},
)
assert response.status_code == 422
def test_create_config_folder_path_traversal_attack(self, authenticated_client: TestClient) -> None:
"""Test that path traversal in file paths is prevented."""
response = authenticated_client.post(
"/config-folders",
json={
"name": "bad-folder",
"mount_path": "/home/user",
"files": {"../../../etc/passwd": "malicious"},
},
)
assert response.status_code == 422
def test_get_config_folder_by_id(self, authenticated_client: TestClient) -> None:
"""Test getting a config folder by ID."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "get-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
# Get it back
response = authenticated_client.get(f"/config-folders/{folder_id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-test"
def test_get_config_folder_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent folder."""
response = authenticated_client.get(f"/config-folders/{uuid.uuid4()}")
assert response.status_code == 404
def test_update_config_folder_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a config folder."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "update-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
# Update it
response = authenticated_client.put(
f"/config-folders/{folder_id}",
json={
"name": "updated-name",
"mount_path": "/workspace",
"files": {"new.txt": "content"},
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["mount_path"] == "/workspace"
def test_delete_config_folder_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a config folder."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "delete-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
# Delete it
response = authenticated_client.delete(f"/config-folders/{folder_id}")
assert response.status_code == 204
# Verify it's gone
get_response = authenticated_client.get(f"/config-folders/{folder_id}")
assert get_response.status_code == 404
def test_add_project_override_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding a project override."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "override-test",
"mount_path": "/home/user",
"files": {"global.txt": "global"},
},
)
folder_id = create_response.json()["id"]
project_id = str(uuid.uuid4())
# Add override
response = authenticated_client.post(
f"/config-folders/{folder_id}/overrides",
json={
"project_id": project_id,
"mount_path": "/workspace",
"files": {"project.txt": "project"},
},
)
assert response.status_code == 200
data = response.json()
assert project_id in data["project_overrides"]
def test_update_project_override_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a project override."""
# Create folder with override
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "update-override-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
project_id = str(uuid.uuid4())
# Add override
authenticated_client.post(
f"/config-folders/{folder_id}/overrides",
json={
"project_id": project_id,
"mount_path": "/workspace",
"files": {"old.txt": "old"},
},
)
# Update override
response = authenticated_client.put(
f"/config-folders/{folder_id}/overrides/{project_id}",
json={
"mount_path": "/app",
"files": {"new.txt": "new"},
},
)
assert response.status_code == 200
data = response.json()
assert data["project_overrides"][project_id]["mount_path"] == "/app"
def test_delete_project_override_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a project override."""
# Create folder with override
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "delete-override-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
project_id = str(uuid.uuid4())
# Add override
authenticated_client.post(
f"/config-folders/{folder_id}/overrides",
json={
"project_id": project_id,
"mount_path": "/workspace",
"files": {},
},
)
# Delete override
response = authenticated_client.delete(
f"/config-folders/{folder_id}/overrides/{project_id}"
)
assert response.status_code == 200
data = response.json()
assert project_id not in data["project_overrides"]
@@ -63,6 +63,13 @@ class TestGitStatus:
assert "new.py" in status.untracked
def test_get_current_branch_handles_unborn_main() -> None:
with tempfile.TemporaryDirectory() as tmpdir:
os.system(f"git init -b main {tmpdir} >/dev/null 2>&1")
assert get_current_branch(tmpdir) == "main"
class TestBranchOperations:
"""Tests for branch management functions."""
@@ -5,7 +5,6 @@ from src.models import Base
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.refresh_token import RefreshToken
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.models.user_config import UserConfig
@@ -6,7 +6,7 @@ import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.project import Project
@@ -53,7 +53,7 @@ def _load_app():
def _mint_token(user_id: str) -> str:
settings = Settings()
return mint_access_token(
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
@@ -0,0 +1,256 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestToolConfigsAPIExtended:
"""Integration tests for tool configs API with new fields."""
def test_create_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
"""Test creating a tool config with all new fields."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "config-test-tool",
"display_name": "Config Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config with new fields
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "ADVANCED_CONFIG",
"value": "test-value",
"config_type": "env",
"port_override": 9090,
"start_command": "python app.py",
"working_directory": "/app",
"environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"},
"volumes": [
{"source": "data", "target": "/data", "type": "bind"}
],
},
)
assert response.status_code == 201
data = response.json()
assert data["key"] == "ADVANCED_CONFIG"
assert data["port_override"] == 9090
assert data["start_command"] == "python app.py"
assert data["working_directory"] == "/app"
assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"}
assert data["volumes"] == [{"source": "data", "target": "/data", "type": "bind"}]
def test_create_tool_config_invalid_port(self, authenticated_client: TestClient) -> None:
"""Test that invalid port numbers are rejected."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "port-test-tool",
"display_name": "Port Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Try to create config with invalid port
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "BAD_PORT",
"value": "test",
"config_type": "env",
"port_override": 99999,
},
)
assert response.status_code == 422
def test_create_tool_config_invalid_volume_structure(self, authenticated_client: TestClient) -> None:
"""Test that invalid volume structures are rejected."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "volume-test-tool",
"display_name": "Volume Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Try to create config with invalid volume
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "BAD_VOLUME",
"value": "test",
"config_type": "env",
"volumes": [{"invalid": "structure"}],
},
)
assert response.status_code == 422
def test_update_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
"""Test updating a tool config with new fields."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "update-config-tool",
"display_name": "Update Config Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config
create_response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "UPDATE_TEST",
"value": "original",
"config_type": "env",
},
)
config_id = create_response.json()["id"]
# Update with new fields
response = authenticated_client.put(
f"/tool-configs/{config_id}",
json={
"value": "updated",
"port_override": 3000,
"start_command": "npm start",
"working_directory": "/workspace",
"environment_variables": {"NODE_ENV": "production"},
"volumes": [{"source": "src", "target": "/app/src", "type": "bind"}],
},
)
assert response.status_code == 200
data = response.json()
assert data["value"] == "updated"
assert data["port_override"] == 3000
assert data["start_command"] == "npm start"
assert data["working_directory"] == "/workspace"
assert data["environment_variables"] == {"NODE_ENV": "production"}
def test_list_tool_configs_returns_new_fields(self, authenticated_client: TestClient) -> None:
"""Test that listing configs returns new fields."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "list-config-tool",
"display_name": "List Config Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config with new fields
authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "LIST_TEST",
"value": "test",
"config_type": "env",
"port_override": 5000,
"environment_variables": {"TEST": "true"},
},
)
# List configs
response = authenticated_client.get("/tool-configs")
assert response.status_code == 200
data = response.json()
assert len(data) > 0
config = data[0]
assert "port_override" in config
assert "start_command" in config
assert "working_directory" in config
assert "environment_variables" in config
assert "volumes" in config
def test_get_tool_config_defaults(self, authenticated_client: TestClient) -> None:
"""Test getting tool config defaults."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "defaults-tool",
"display_name": "Defaults Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n volumes:\n - \"{{REPO_PATH}}:/workspace\"\n",
"required_variables": ["REPO_PATH"],
},
)
tool_id = tool_response.json()["id"]
# Get defaults
response = authenticated_client.get(f"/tool-configs/defaults/{tool_id}")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == tool_id
assert "suggested_configs" in data
def test_tool_config_backward_compatibility(self, authenticated_client: TestClient) -> None:
"""Test that old configs without new fields still work."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "backward-compat-tool",
"display_name": "Backward Compat Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config without new fields (simulating old client)
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "OLD_STYLE",
"value": "value",
"config_type": "env",
},
)
assert response.status_code == 201
data = response.json()
assert data["key"] == "OLD_STYLE"
# New fields should have default values
assert data["port_override"] is None
assert data["start_command"] is None
assert data["working_directory"] is None
assert data["environment_variables"] is None
assert data["volumes"] is None
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.tool_type import ToolType
@@ -54,7 +54,7 @@ def _load_app():
def _mint_token(user_id: str) -> str:
settings = Settings()
return mint_access_token(
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
@@ -0,0 +1,188 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestToolTypesAPIExtended:
"""Integration tests for tool types API with new fields."""
def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with dockerfile definition."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "dockerfile-tool",
"display_name": "Dockerfile Tool",
"category": "utility",
"interfaces": ["terminal"],
"default_port": 8080,
"definition_type": "dockerfile",
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
"required_variables": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "dockerfile-tool"
assert data["definition_type"] == "dockerfile"
assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask"
def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None:
"""Test creating a tool type with readiness probe."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "probed-tool",
"display_name": "Probed Tool",
"category": "utility",
"interfaces": ["web"],
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"readiness_probe": {
"command": "curl -f http://localhost:8080",
"timeout": 30,
"interval": 2,
},
"required_variables": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
assert data["readiness_probe"]["timeout"] == 30
def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None:
"""Test that invalid definition types are rejected."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "invalid-tool",
"display_name": "Invalid Tool",
"default_port": 8080,
"definition_type": "invalid",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
assert response.status_code == 422
def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None:
"""Test that dockerfile type requires dockerfile_template."""
response = authenticated_client.post(
"/tool-types",
json={
"name": "no-dockerfile",
"display_name": "No Dockerfile",
"default_port": 8080,
"definition_type": "dockerfile",
"required_variables": [],
},
)
assert response.status_code == 422
def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None:
"""Test updating a tool type with new fields."""
# Create tool type first
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "update-test-tool",
"display_name": "Update Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = create_response.json()["id"]
# Update it
response = authenticated_client.put(
f"/tool-types/{tool_id}",
json={
"display_name": "Updated Name",
"readiness_probe": {
"command": "curl -f http://localhost:8080/health",
"timeout": 60,
"interval": 5,
},
},
)
assert response.status_code == 200
data = response.json()
assert data["display_name"] == "Updated Name"
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health"
def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
"""Test validating compose template."""
response = authenticated_client.post(
"/tool-types/validate",
json={
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None:
"""Test validating invalid compose template."""
response = authenticated_client.post(
"/tool-types/validate",
json={
"definition_type": "compose",
"compose_template": "invalid: yaml: [",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is False
assert "errors" in data
def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None:
"""Test validating dockerfile template."""
response = authenticated_client.post(
"/tool-types/validate",
json={
"definition_type": "dockerfile",
"dockerfile_template": "FROM python:3.11\nRUN pip install flask",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None:
"""Test that GET returns new fields."""
# Create tool type with all fields
create_response = authenticated_client.post(
"/tool-types",
json={
"name": "full-tool",
"display_name": "Full Tool",
"category": "editor",
"interfaces": ["web", "terminal"],
"default_port": 8443,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
"readiness_probe": {
"command": "curl -f http://localhost:8443",
"timeout": 30,
"interval": 2,
},
"required_variables": ["REPO_PATH"],
},
)
tool_id = create_response.json()["id"]
# Get it
response = authenticated_client.get(f"/tool-types/{tool_id}")
assert response.status_code == 200
data = response.json()
assert data["definition_type"] == "compose"
assert data["category"] == "editor"
assert data["interfaces"] == ["web", "terminal"]
assert "readiness_probe" in data
+2 -2
View File
@@ -8,7 +8,7 @@ import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from src.auth.jwt_service import mint_access_token
from src.auth.session import create_session_cookie
from src.config import Settings, build_database_url
from src.models import Base
from src.models.user import User
@@ -78,7 +78,7 @@ def _insert_test_user(user_id: str) -> None:
def _create_auth_cookie(user_id: str) -> str:
settings = Settings()
return mint_access_token(
return create_session_cookie(
settings=settings,
subject=user_id,
email="test@headquarter.local",
+151
View File
@@ -0,0 +1,151 @@
"""Unit tests for docker build service."""
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src.services.docker_build import build_image
class TestBuildImage:
"""Tests for build_image function."""
@patch("subprocess.run")
def test_builds_image_successfully(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="Successfully built abc123",
stderr="",
)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test-image:latest")
assert result[0] == 0
assert "Successfully built" in result[1]
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "test-image:latest" in call_args[0][0]
assert "build" in call_args[0][0]
@patch("subprocess.run")
def test_build_fails(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Error: failed to build",
)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM invalid:image", "test-image:latest")
assert result[0] == 1
assert "failed to build" in result[2]
@patch("subprocess.run")
def test_build_with_tag(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="",
stderr="",
)
with tempfile.TemporaryDirectory() as tmpdir:
build_image(tmpdir, "FROM python:3.11", "my-registry/tool:v1.0")
call_args = mock_run.call_args[0][0]
assert "my-registry/tool:v1.0" in call_args
@patch("subprocess.run")
def test_build_command_structure(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_image(tmpdir, "FROM python:3.11", "test:latest")
cmd = mock_run.call_args[0][0]
assert cmd[0] == "docker"
assert cmd[1] == "build"
assert "-t" in cmd
assert "test:latest" in cmd
assert tmpdir in cmd
@patch("subprocess.run")
def test_build_writes_dockerfile(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
dockerfile_content = "FROM python:3.11\\nRUN pip install flask"
build_image(tmpdir, dockerfile_content, "test:latest")
dockerfile_path = Path(tmpdir) / "Dockerfile"
assert dockerfile_path.exists()
assert dockerfile_path.read_text() == dockerfile_content
@patch("subprocess.run")
def test_build_writes_context_files(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"requirements.txt": "flask==2.0\\nnumpy==1.21",
"app.py": "from flask import Flask\\napp = Flask(__name__)",
}
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
req_path = Path(tmpdir) / "requirements.txt"
app_path = Path(tmpdir) / "app.py"
assert req_path.exists()
assert req_path.read_text() == "flask==2.0\\nnumpy==1.21"
assert app_path.exists()
assert app_path.read_text() == "from flask import Flask\\napp = Flask(__name__)"
@patch("subprocess.run")
def test_build_creates_nested_directories(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"src/app.py": "print('hello')",
}
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
app_path = Path(tmpdir) / "src" / "app.py"
assert app_path.exists()
@patch("subprocess.run")
def test_build_prevents_path_traversal(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmpdir:
build_context = {
"../../../etc/passwd": "root:x:0:0",
}
with pytest.raises(ValueError, match="escapes instance directory"):
build_image(tmpdir, "FROM python:3.11", "test:latest", build_context)
mock_run.assert_not_called()
@patch("subprocess.run")
def test_build_timeout(self, mock_run) -> None:
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker", "build"], timeout=300)
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
assert result[0] == 1
assert "timed out" in result[2].lower()
@patch("subprocess.run")
def test_build_exception(self, mock_run) -> None:
mock_run.side_effect = OSError("Docker not available")
with tempfile.TemporaryDirectory() as tmpdir:
result = build_image(tmpdir, "FROM python:3.11", "test:latest")
assert result[0] == 1
assert "Docker not available" in result[2]
@@ -0,0 +1,28 @@
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from src.api.git_repositories import _build_provider_clone_url, _preflight_remote_repository
def test_build_provider_clone_url_uses_fixed_host() -> None:
assert _build_provider_clone_url("alice", "demo") == "git@git.commumedia.org:alice/demo.git"
def test_preflight_remote_repository_allows_accessible_repo() -> None:
completed = Mock(returncode=0)
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
_preflight_remote_repository("git@git.commumedia.org:alice/demo.git")
run_mock.assert_called_once()
def test_preflight_remote_repository_rejects_missing_repo() -> None:
completed = Mock(returncode=128)
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
with pytest.raises(HTTPException) as exc_info:
_preflight_remote_repository("git@git.commumedia.org:alice/missing.git")
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "repository not found or inaccessible"
@@ -0,0 +1,64 @@
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from src.api.git_repositories import _clone_working_repository, _init_working_repository
from src.utils.git_control import create_branch
def test_clone_working_repository_uses_normal_clone() -> None:
completed = Mock(returncode=0, stderr="")
with patch("src.api.git_repositories.subprocess.run", return_value=completed) as run_mock:
_clone_working_repository("git@git.commumedia.org:alice/demo.git", "/tmp/demo.git")
run_mock.assert_called_once()
assert run_mock.call_args.args[0] == ["git", "clone", "git@git.commumedia.org:alice/demo.git", "/tmp/demo.git"]
def test_clone_working_repository_raises_on_failure() -> None:
completed = Mock(returncode=128, stderr="fatal: repository not found")
with patch("src.api.git_repositories.subprocess.run", return_value=completed):
with pytest.raises(HTTPException) as exc_info:
_clone_working_repository("git@git.commumedia.org:alice/missing.git", "/tmp/missing.git")
assert exc_info.value.status_code == 400
assert "failed to clone repository" in exc_info.value.detail
def test_init_working_repository_prefers_init_b() -> None:
init_b = Mock(returncode=0, stderr="")
with patch("src.api.git_repositories.subprocess.run", return_value=init_b) as run_mock:
_init_working_repository("/tmp/new-repo")
assert run_mock.call_args.args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
def test_init_working_repository_falls_back_to_symbolic_ref() -> None:
init_b = Mock(returncode=1, stderr="unknown switch `b'")
init_ok = Mock(returncode=0, stderr="")
symbolic_ref = Mock(returncode=0, stderr="")
with patch("src.api.git_repositories.subprocess.run", side_effect=[init_b, init_ok, symbolic_ref]) as run_mock:
_init_working_repository("/tmp/new-repo")
assert run_mock.call_args_list[0].args[0] == ["git", "init", "-b", "main", "/tmp/new-repo"]
assert run_mock.call_args_list[1].args[0] == ["git", "init", "/tmp/new-repo"]
assert run_mock.call_args_list[2].args[0] == ["git", "-C", "/tmp/new-repo", "symbolic-ref", "HEAD", "refs/heads/main"]
def test_create_branch_uses_orphan_checkout_when_head_is_unborn() -> None:
call_count = 0
def mock_run(repo_path: str, *args: str) -> str:
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("fatal: Needed a single revision")
return ""
with patch("src.utils.git_control._run_git_command", side_effect=mock_run) as run_mock:
create_branch("/tmp/new-repo", "feature/test")
assert run_mock.call_args_list[0].args[1:] == ("rev-parse", "--verify", "HEAD^{commit}")
assert run_mock.call_args_list[1].args[1:] == ("checkout", "--orphan", "feature/test")
@@ -39,3 +39,18 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
assert module.revision == "0002_refresh_tokens"
assert module.down_revision == "0001_initial_schema"
@pytest.mark.unit
def test_config_profiles_migration_has_expected_revision_chain() -> None:
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
spec = spec_from_file_location("add_config_profiles", migration_path)
assert spec is not None
assert spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "0013_add_config_profiles"
assert module.down_revision == "0012_default_port_req"
+217
View File
@@ -0,0 +1,217 @@
"""Unit tests for readiness probe service."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from src.services.readiness_probe import execute_probe
class TestExecuteProbe:
"""Tests for execute_probe function."""
@patch("subprocess.run")
async def test_probe_succeeds_first_attempt(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="healthy",
stderr="",
)
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080")
assert result is True
assert any("Success" in log for log in logs)
mock_run.assert_called_once_with(
["docker", "exec", "container-123", "sh", "-c", "curl -f http://localhost:8080"],
capture_output=True,
text=True,
timeout=2,
)
@patch("subprocess.run")
async def test_probe_fails_then_succeeds(self, mock_run) -> None:
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
MagicMock(returncode=1, stdout="", stderr="Connection refused"),
MagicMock(returncode=0, stdout="healthy", stderr=""),
]
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=10, interval=0.1)
assert result is True
assert mock_run.call_count == 3
assert any("Attempt 1: Failed" in log for log in logs)
assert any("Attempt 3: Success" in log for log in logs)
@patch("subprocess.run")
async def test_probe_times_out(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=1,
stdout="",
stderr="Connection refused",
)
result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=0.5, interval=0.2)
assert result is False
assert any("timed out" in log.lower() for log in logs)
@patch("subprocess.run")
async def test_probe_command_not_found(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=127,
stdout="",
stderr="command not found",
)
result, logs = await execute_probe("container-123", "nonexistent-command", timeout=1, interval=0.3)
assert result is False
assert any("exit code 127" in log for log in logs)
@patch("subprocess.run")
async def test_probe_exception(self, mock_run) -> None:
mock_run.side_effect = OSError("Docker not available")
result, logs = await execute_probe("container-123", "curl http://localhost", timeout=1, interval=0.3)
assert result is False
assert any("Error" in log for log in logs)
@patch("subprocess.run")
async def test_probe_with_special_characters(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="",
stderr="",
)
cmd = "bash -c 'echo \"hello world\" && exit 0'"
await execute_probe("container-123", cmd)
call_args = mock_run.call_args
assert cmd in call_args[0][0]
@patch("subprocess.run")
async def test_probe_captures_stdout(self, mock_run) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="Server is ready\\nVersion: 1.0",
stderr="",
)
result, logs = await execute_probe("container-123", "cat /app/status")
assert result is True
assert any("Server is ready" in log for log in logs)
class TestIntegrationScenarios:
"""Integration-style tests with realistic scenarios."""
@patch("subprocess.run")
async def test_web_server_probe(self, mock_run) -> None:
"""Test typical web server health check."""
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=0, stdout="OK", stderr=""),
]
result, logs = await execute_probe(
"web-container",
"curl -f http://localhost:8080/health",
timeout=10,
interval=0.2,
)
assert result is True
assert mock_run.call_count == 4
@patch("subprocess.run")
async def test_command_probe(self, mock_run) -> None:
"""Test command availability check."""
mock_run.return_value = MagicMock(
returncode=0,
stdout="opencode 1.0.0",
stderr="",
)
result, logs = await execute_probe(
"tool-container",
"which opencode && opencode --version",
timeout=30,
interval=2,
)
assert result is True
assert any("opencode 1.0.0" in log for log in logs)
@patch("subprocess.run")
async def test_database_probe(self, mock_run) -> None:
"""Test database readiness check."""
mock_run.side_effect = [
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr=""),
MagicMock(returncode=0, stdout="/var/run/postgresql:5432 - accepting connections", stderr=""),
]
result, logs = await execute_probe(
"db-container",
"pg_isready -U postgres",
timeout=10,
interval=0.3,
)
assert result is True
assert mock_run.call_count == 3
@patch("subprocess.run")
async def test_file_probe(self, mock_run) -> None:
"""Test file existence check."""
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result, logs = await execute_probe(
"app-container",
"[ -f /app/ready ]",
timeout=10,
interval=1,
)
assert result is True
@patch("subprocess.run")
async def test_slow_starting_service(self, mock_run) -> None:
"""Test service that takes time to start."""
# Simulate 5 failures before success
side_effects = [MagicMock(returncode=1, stdout="", stderr="")] * 5
side_effects.append(MagicMock(returncode=0, stdout="Ready", stderr=""))
mock_run.side_effect = side_effects
result, logs = await execute_probe(
"slow-container",
"curl -f http://localhost:8080",
timeout=10,
interval=0.2,
)
assert result is True
assert mock_run.call_count == 6
assert any("Attempt 6: Success" in log for log in logs)
@patch("subprocess.run")
async def test_zero_timeout_immediate_return(self, mock_run) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
result, logs = await execute_probe(
"container",
"test",
timeout=0,
interval=1,
)
assert result is False
assert any("timed out" in log.lower() for log in logs)
+1371
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Headquarter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+31 -1
View File
@@ -16,7 +16,10 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0"
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -6352,6 +6355,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-web-links": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
"integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+4 -1
View File
@@ -19,7 +19,10 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0"
"tailwindcss": "^3.3.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
+796
View File
@@ -0,0 +1,796 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Headquarter - UI Preview</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f4f1ea;
--panel: #fffef9;
--ink: #1d1d1b;
--muted: #5f5b55;
--brand: #275d4b;
--brand-strong: #154236;
--border: #d8d0c5;
--primary: #275d4b;
--primary-fg: #fffef9;
--color-primary: #275d4b;
--success: #2f8f62;
--success-light: rgba(47, 143, 98, 0.14);
--warning: #c08a1e;
--warning-light: rgba(192, 138, 30, 0.14);
--danger: #b94a3c;
--danger-light: rgba(185, 74, 60, 0.14);
--info: #4f7fb8;
--info-light: rgba(79, 127, 184, 0.14);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--font-size-xs: clamp(0.625rem, 0.6rem + 0.125vw, 0.75rem);
--font-size-sm: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
--font-size-base: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
--font-size-lg: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
--font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
}
/* App Shell */
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.shell-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px);
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
color: var(--ink);
text-decoration: none;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-chip {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-size: 0.9rem;
color: var(--ink);
text-decoration: none;
}
.ghost-button {
border: 1px solid var(--border);
background: transparent;
border-radius: 10px;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
color: var(--muted);
}
.shell-body {
display: grid;
grid-template-columns: 230px 1fr;
min-height: calc(100vh - 57px);
}
.shell-nav {
border-right: 1px solid var(--border);
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent);
}
.nav-item {
padding: 0.65rem 0.75rem;
border-radius: 10px;
color: var(--muted);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
}
.nav-item:hover {
background: #ece7df;
color: var(--ink);
}
.nav-item-active {
background: var(--brand);
color: #f7fff7;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--primary);
color: var(--primary-fg);
border-radius: 9px;
font-size: 11px;
font-weight: 600;
margin-left: auto;
}
.nav-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.nav-section-title {
margin-top: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.session-item {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
}
.session-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
display: inline-block;
margin-right: 0.25rem;
}
.session-status.running {
background: var(--success);
}
.shell-content {
padding: 1.25rem;
overflow-x: hidden;
}
/* Common Components */
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.stack-sm {
gap: 0.5rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem;
}
.muted {
color: var(--muted);
}
.eyebrow {
margin: 0;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.primary-button {
background: var(--brand);
color: white;
border-radius: 10px;
border: 1px solid transparent;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
}
.primary-button:hover {
background: var(--brand-strong);
}
.secondary-button {
border-color: var(--border);
background: var(--panel);
border-radius: 10px;
border: 1px solid var(--border);
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
}
/* Home Page */
.home-page {
max-width: 1240px;
}
.home-hero {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: flex-start;
}
.home-hero-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.home-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-4);
}
.home-summary-card .card-label {
margin: 0;
color: var(--muted);
font-size: 0.875rem;
}
.home-summary-card .card-value {
margin: 0.45rem 0 0;
font-size: 1.6rem;
font-weight: 700;
}
.home-section h2,
.home-section h3 {
margin: 0;
}
.home-session-grid,
.home-project-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.session-card {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
.session-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.running {
background: var(--success-light);
color: var(--success);
}
.status-badge.building {
background: var(--warning-light);
color: var(--warning);
}
.status-badge.pending {
background: var(--info-light);
color: var(--info);
}
.recent-sessions-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.recent-session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
.recent-session-name {
font-weight: 500;
}
.create-session-form .form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4);
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-field input,
.form-field select,
.form-field textarea {
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: 10px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
/* Settings Page */
.settings-page {
max-width: 1240px;
}
.settings-header {
padding: 1.5rem;
}
.settings-tabs {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.settings-tab {
padding: 0.6rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--panel);
text-decoration: none;
cursor: pointer;
}
.settings-tab.active {
background: var(--brand);
color: white;
border-color: transparent;
}
.settings-panel {
padding: 1.5rem;
}
.settings-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.success-text {
color: var(--success);
}
.error-text {
color: var(--danger);
}
/* Preview Switcher */
.preview-switcher {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.preview-switcher button {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font: inherit;
color: var(--muted);
}
.preview-switcher button.active {
background: var(--brand);
color: white;
}
.page-preview {
display: none;
}
.page-preview.active {
display: block;
}
/* Responsive */
@media (max-width: 767px) {
.shell-body {
grid-template-columns: 1fr;
}
.shell-nav {
flex-direction: row;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
}
.home-hero {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="shell">
<header class="shell-header">
<a href="#" class="brand">Headquarter</a>
<div class="header-actions">
<a href="#" class="user-chip">User</a>
<button class="ghost-button">Logout</button>
</div>
</header>
<div class="shell-body">
<aside class="shell-nav" aria-label="Primary navigation">
<a href="#" class="nav-item nav-item-active">
<span>🏠</span> Home
<span class="nav-badge">3</span>
</a>
<a href="#" class="nav-item">
<span>📁</span> Projects
</a>
<a href="#" class="nav-item">
<span>⚙️</span> Settings
</a>
<div class="nav-divider"></div>
<div class="nav-section-title">Live sessions</div>
<a href="#" class="nav-item session-item">
<span class="session-status running"></span>
<span>Dev Environment</span>
</a>
<a href="#" class="nav-item session-item">
<span class="session-status running"></span>
<span>Jupyter Lab</span>
</a>
<a href="#" class="nav-item session-item">
<span class="session-status"></span>
<span>Code Server</span>
</a>
</aside>
<main class="shell-content">
<!-- HOME PAGE PREVIEW -->
<div id="home-preview" class="page-preview active">
<section class="stack home-page">
<header class="home-hero card">
<div class="stack-sm">
<p class="eyebrow">Workspace overview</p>
<h1>Home</h1>
<p class="muted">Open sessions, available projects, and the fastest path back into work.</p>
</div>
<div class="home-hero-actions">
<button class="primary-button">New Project</button>
<button class="secondary-button">Settings</button>
</div>
</header>
<div class="home-summary-grid">
<article class="card home-summary-card">
<p class="card-label">Open sessions</p>
<p class="card-value">3</p>
</article>
<article class="card home-summary-card">
<p class="card-label">Projects</p>
<p class="card-value">5</p>
</article>
<article class="card home-summary-card">
<p class="card-label">Repositories</p>
<p class="card-value">12</p>
</article>
</div>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Open sessions</p>
<h2>3</h2>
</div>
</div>
<div class="home-session-grid">
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Dev Environment</h3>
<span class="status-badge running">running</span>
</div>
<p class="muted">Acme Corp · main</p>
<p class="muted">VS Code Server</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Jupyter Lab</h3>
<span class="status-badge running">running</span>
</div>
<p class="muted">Data Science · experiments</p>
<p class="muted">Jupyter Notebook</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
<article class="card session-card">
<div class="stack-sm">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<h3>Database Console</h3>
<span class="status-badge building">building</span>
</div>
<p class="muted">Backend API · staging</p>
<p class="muted">PostgreSQL Client</p>
</div>
<div class="session-actions">
<button class="secondary-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Tunnel</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Stop</button>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; color: var(--danger);">Delete</button>
</div>
</article>
</div>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Available projects</p>
<h2>5</h2>
</div>
<button class="secondary-button">View all</button>
</div>
<div class="home-project-grid">
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Acme Corp</h3>
<p class="muted">Main product development</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Data Science</h3>
<p class="muted">ML experiments and notebooks</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
<article class="card" style="box-shadow: 0 1px 0 rgba(0,0,0,0.02);">
<div class="stack-sm">
<h3>Backend API</h3>
<p class="muted">REST API services</p>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem; margin-top: 0.5rem;">Open Workspace</button>
</article>
</div>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Quick create</p>
<h2>Start a session</h2>
</div>
</div>
<form class="stack create-session-form">
<div class="form-row">
<label class="form-field">
Project
<select>
<option>Select project...</option>
<option>Acme Corp</option>
<option>Data Science</option>
</select>
</label>
<label class="form-field">
Repository
<select disabled>
<option>Select repository...</option>
</select>
</label>
<label class="form-field">
Tool type
<select>
<option>Select tool...</option>
<option>VS Code Server</option>
<option>Jupyter Lab</option>
</select>
</label>
</div>
<label class="form-field">
Display name
<input type="text" placeholder="My Development Environment">
</label>
<div class="form-actions">
<button class="primary-button" type="submit">Create Session</button>
</div>
</form>
</section>
<section class="card stack home-section">
<div class="page-header">
<div>
<p class="eyebrow">Recent sessions</p>
<h2>2</h2>
</div>
</div>
<div class="recent-sessions-list">
<article class="recent-session-item">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span class="recent-session-name">Old Dev Box</span>
<span class="muted">Acme Corp · VS Code Server</span>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
</article>
<article class="recent-session-item">
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span class="recent-session-name">ML Training</span>
<span class="muted">Data Science · Jupyter Lab</span>
</div>
<button class="ghost-button" style="font-size: 0.85rem; padding: 0.42rem 0.7rem;">Open</button>
</article>
</div>
</section>
</section>
</div>
<!-- SETTINGS PAGE PREVIEW -->
<div id="settings-preview" class="page-preview">
<section class="stack settings-page">
<header class="settings-header card stack-sm">
<div>
<p class="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p class="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
</header>
<nav class="settings-tabs" aria-label="Settings sections">
<a href="#" class="settings-tab active">General</a>
<a href="#" class="settings-tab">SSH Keys</a>
<a href="#" class="settings-tab">Tool Types</a>
<a href="#" class="settings-tab">Tool Configs</a>
</nav>
<div class="settings-panel card">
<div class="stack">
<h2>General</h2>
<label class="form-field">
Theme
<select>
<option>System</option>
<option>Light</option>
<option>Dark</option>
</select>
</label>
<label class="form-field">
Git user name
<input type="text" placeholder="Your git commit name" value="John Doe">
</label>
<label class="form-field">
Git user email
<input type="email" placeholder="your.email@example.com" value="john@example.com">
</label>
<label class="form-field">
Default editor
<input type="text" placeholder="e.g., vscode, vim, cursor" value="vscode">
</label>
<div class="settings-actions">
<button class="primary-button">Save Settings</button>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</div>
<div class="preview-switcher">
<button class="active" onclick="showPage('home')">Home</button>
<button onclick="showPage('settings')">Settings</button>
</div>
<script>
function showPage(page) {
document.querySelectorAll('.page-preview').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.preview-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(page + '-preview').classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
} from "../api/config_folders";
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
interceptors: {
response: {
use: vi.fn(),
},
},
},
shouldSkipAuthRedirect: vi.fn(() => false),
}));
describe("config_folders API", () => {
describe("listConfigFolders", () => {
it("returns folders with files and overrides", async () => {
const mockResponse = {
data: [
{
id: "folder-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
],
};
mockGet.mockResolvedValue(mockResponse);
const result = await listConfigFolders();
expect(result[0].name).toBe("my-dotfiles");
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
expect(mockGet).toHaveBeenCalledWith("/config-folders");
});
});
describe("createConfigFolder", () => {
it("creates folder with files", async () => {
const mockResponse = {
data: {
id: "folder-new",
name: "new-folder",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost" },
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPost.mockResolvedValue(mockResponse);
const result = await createConfigFolder({
name: "new-folder",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost" },
});
expect(result.name).toBe("new-folder");
expect(mockPost).toHaveBeenCalledWith(
"/config-folders",
expect.objectContaining({
name: "new-folder",
mount_path: "/workspace",
})
);
});
});
describe("updateConfigFolder", () => {
it("updates folder files", async () => {
const mockResponse = {
data: {
id: "folder-1",
name: "updated-folder",
mount_path: "/home/user",
files: { ".bashrc": "alias ll='ls -la'" },
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPut.mockResolvedValue(mockResponse);
const result = await updateConfigFolder("folder-1", {
files: { ".bashrc": "alias ll='ls -la'" },
});
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
expect(mockPut).toHaveBeenCalledWith(
"/config-folders/folder-1",
expect.objectContaining({
files: { ".bashrc": "alias ll='ls -la'" },
})
);
});
});
describe("deleteConfigFolder", () => {
it("deletes folder", async () => {
mockDelete.mockResolvedValue({ data: undefined });
await deleteConfigFolder("folder-1");
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
});
});
});
+95
View File
@@ -0,0 +1,95 @@
import { apiClient } from "./client";
export interface ConfigFolder {
id: string;
user_id: string;
name: string;
description: string | null;
mount_path: string;
files: Record<string, string>;
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateConfigFolderRequest {
name: string;
description?: string;
mount_path: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface UpdateConfigFolderRequest {
name?: string;
description?: string;
mount_path?: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface ProjectOverrideRequest {
mount_path?: string;
files?: Record<string, string>;
}
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
return response.data;
};
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
return response.data;
};
export const createConfigFolder = async (
data: CreateConfigFolderRequest
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
return response.data;
};
export const updateConfigFolder = async (
id: string,
data: UpdateConfigFolderRequest
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(`/config-folders/${id}`, data);
return response.data;
};
export const deleteConfigFolder = async (id: string): Promise<void> => {
await apiClient.delete(`/config-folders/${id}`);
};
export const addProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
);
return response.data;
};
export const updateProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
);
return response.data;
};
export const deleteProjectOverride = async (
id: string,
projectId: string
): Promise<void> => {
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
};
+27
View File
@@ -5,6 +5,8 @@ export interface ToolInstance {
name: string;
display_name: string;
tool_type_id: string;
tool_type_name: string;
tool_type_interfaces: string[];
status: string;
url: string | null;
port: number | null;
@@ -16,8 +18,11 @@ export interface Session {
display_name: string;
tool_type_name: string;
tool_icon: string;
tool_type_interfaces: string[];
repository_name: string;
repository_id: string;
project_name: string;
project_id: string;
status: string;
url: string | null;
}
@@ -95,3 +100,25 @@ export async function getUserSessions(): Promise<Session[]> {
const response = await apiClient.get("/users/me/sessions");
return response.data.sessions;
}
export async function checkInstanceHealth(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
);
return response.data;
}
export async function recreateInstanceTunnel(
projectId: string,
repoId: string,
instanceId: string
): Promise<{ status: string; url?: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
);
return response.data;
}
+6 -4
View File
@@ -5,13 +5,15 @@ export interface UserConfig {
theme: string;
git_user_name: string | null;
git_user_email: string | null;
last_session_id: string | null;
}
export interface UserConfigUpdate {
default_editor?: string;
theme?: string;
git_user_name?: string;
git_user_email?: string;
default_editor?: string | null;
theme?: string | null;
git_user_name?: string | null;
git_user_email?: string | null;
last_session_id?: string | null;
}
export const getUserConfig = async (): Promise<UserConfig> => {
+75
View File
@@ -0,0 +1,75 @@
import { apiClient } from "./client";
export interface ToolConfig {
id: string;
tool_type_id: string;
project_id: string | null;
key: string;
value: string;
config_type: string;
file_path: string | null;
port_override: number | null;
start_command: string | null;
working_directory: string | null;
environment_variables: Record<string, string> | null;
volumes: Array<{ source: string; target: string; type?: string }> | null;
}
export interface CreateToolConfigRequest {
tool_type_id: string;
project_id?: string;
key: string;
value: string;
config_type?: string;
file_path?: string;
port_override?: number;
start_command?: string;
working_directory?: string;
environment_variables?: Record<string, string>;
volumes?: Array<{ source: string; target: string; type?: string }>;
}
export const listToolConfigs = async (
tool_type_id?: string,
project_id?: string
): Promise<ToolConfig[]> => {
const params = new URLSearchParams();
if (tool_type_id) params.append("tool_type_id", tool_type_id);
if (project_id) params.append("project_id", project_id);
const response = await apiClient.get<{ configs: ToolConfig[] }>(
`/tool-configs?${params.toString()}`
);
return response.data.configs;
};
export const createToolConfig = async (
data: CreateToolConfigRequest
): Promise<ToolConfig> => {
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data);
return response.data.configs[0];
};
export const updateToolConfig = async (
id: string,
data: CreateToolConfigRequest
): Promise<ToolConfig> => {
const response = await apiClient.put<{ configs: ToolConfig[] }>(
`/tool-configs/${id}`,
data
);
return response.data.configs[0];
};
export const deleteToolConfig = async (id: string): Promise<void> => {
await apiClient.delete(`/tool-configs/${id}`);
};
export const getToolConfigDefaults = async (
toolTypeId: string
): Promise<ToolConfig> => {
const response = await apiClient.get<ToolConfig>(
`/tool-configs/defaults/${toolTypeId}`
);
return response.data;
};
+227
View File
@@ -0,0 +1,227 @@
import { describe, expect, it, vi } from "vitest";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
validateToolType,
} from "../api/tool_types";
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
interceptors: {
response: {
use: vi.fn(),
},
},
},
shouldSkipAuthRedirect: vi.fn(() => false),
}));
describe("tool_types API", () => {
describe("listToolTypes", () => {
it("returns tool types with new fields", async () => {
const mockResponse = {
data: [
{
id: "type-1",
name: "custom-tool",
display_name: "Custom Tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
],
};
mockGet.mockResolvedValue(mockResponse);
const result = await listToolTypes();
expect(result[0].definition_type).toBe("dockerfile");
expect(result[0].dockerfile_template).toBe("FROM python:3.11");
expect(result[0].readiness_probe).toEqual({
command: "python --version",
timeout: 30,
interval: 2,
});
});
it("returns compose tool types", async () => {
const mockResponse = {
data: [
{
id: "type-1",
name: "code-server",
definition_type: "compose",
compose_template: "version: '3.8'",
dockerfile_template: null,
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
],
};
mockGet.mockResolvedValue(mockResponse);
const result = await listToolTypes();
expect(result[0].definition_type).toBe("compose");
expect(result[0].dockerfile_template).toBeNull();
});
});
describe("createToolType", () => {
it("creates tool type with dockerfile", async () => {
const mockResponse = {
data: {
id: "new-type",
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPost.mockResolvedValue(mockResponse);
const result = await createToolType({
name: "docker-tool",
display_name: "Docker Tool",
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
default_port: 3000,
required_variables: [],
});
expect(result.definition_type).toBe("dockerfile");
expect(mockPost).toHaveBeenCalledWith(
"/tool-types",
expect.objectContaining({
definition_type: "dockerfile",
dockerfile_template: "FROM node:18",
})
);
});
it("creates tool type with readiness probe", async () => {
const mockResponse = {
data: {
id: "new-type",
name: "probed-tool",
readiness_probe: {
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
},
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPost.mockResolvedValue(mockResponse);
const result = await createToolType({
name: "probed-tool",
display_name: "Probed Tool",
compose_template: "version: '3.8'",
default_port: 8080,
required_variables: [],
readiness_probe: {
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
},
});
expect(result.readiness_probe).toEqual({
command: "curl -f http://localhost:8080",
timeout: 60,
interval: 3,
});
});
});
describe("validateToolType", () => {
it("validates tool type by id", async () => {
const mockResponse = {
data: { valid: true, errors: [] },
};
mockGet.mockResolvedValue(mockResponse);
const result = await validateToolType("type-1");
expect(result.valid).toBe(true);
expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate");
});
it("returns validation errors", async () => {
const mockResponse = {
data: { valid: false, errors: ["Invalid YAML"] },
};
mockGet.mockResolvedValue(mockResponse);
const result = await validateToolType("type-1");
expect(result.valid).toBe(false);
expect(result.errors).toContain("Invalid YAML");
});
});
describe("updateToolType", () => {
it("updates tool type with new fields", async () => {
const mockResponse = {
data: {
id: "type-1",
name: "updated-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
build_context: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPut.mockResolvedValue(mockResponse);
const result = await updateToolType("type-1", {
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11",
});
expect(result.definition_type).toBe("dockerfile");
expect(mockPut).toHaveBeenCalledWith(
"/tool-types/type-1",
expect.objectContaining({
definition_type: "dockerfile",
})
);
});
});
describe("deleteToolType", () => {
it("deletes tool type", async () => {
mockDelete.mockResolvedValue({ data: undefined });
await deleteToolType("type-1");
expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1");
});
});
});
+34 -2
View File
@@ -1,11 +1,24 @@
import { apiClient } from "./client";
export interface ReadinessProbe {
command: string;
timeout: number;
interval: number;
}
export interface ToolType {
id: string;
name: string;
display_name: string;
description: string | null;
compose_template: string;
category: string;
interfaces: string[];
default_port: number | null;
definition_type: 'compose' | 'dockerfile';
compose_template: string | null;
dockerfile_template: string | null;
build_context: Record<string, string> | null;
readiness_probe: ReadinessProbe | null;
required_variables: string[];
is_builtin: boolean;
created_by_id: string | null;
@@ -17,14 +30,28 @@ export interface CreateToolTypeRequest {
name: string;
display_name: string;
description?: string;
compose_template: string;
category?: string;
interfaces?: string[];
default_port: number;
definition_type?: 'compose' | 'dockerfile';
compose_template?: string;
dockerfile_template?: string;
build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe;
required_variables: string[];
}
export interface UpdateToolTypeRequest {
display_name?: string;
description?: string;
category?: string;
interfaces?: string[];
default_port?: number;
definition_type?: 'compose' | 'dockerfile';
compose_template?: string;
dockerfile_template?: string;
build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe;
required_variables?: string[];
}
@@ -51,3 +78,8 @@ export const updateToolType = async (id: string, data: UpdateToolTypeRequest): P
export const deleteToolType = async (id: string): Promise<void> => {
await apiClient.delete(`/tool-types/${id}`);
};
export const validateToolType = async (id: string): Promise<{ valid: boolean; errors?: string[] }> => {
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`);
return response.data;
};
+25 -19
View File
@@ -10,21 +10,20 @@ import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Dashboard", icon: "dashboard" },
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
{ to: "/tool-types", label: "Tool Types", icon: "code" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/settings", label: "Settings", icon: "settings" }
];
const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running";
return (
<a
href={session.url || "#"}
target="_blank"
rel="noopener noreferrer"
href={session.url ?? `/projects/${session.project_id}`}
target={session.url ? "_blank" : undefined}
rel={session.url ? "noopener noreferrer" : undefined}
className="nav-item session-item"
title={`${session.display_name} (${session.status})`}
>
@@ -83,22 +82,29 @@ export const AppShell = () => {
<div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
</NavLink>
))}
{NAV_ITEMS.map((item) => {
const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{isHome && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Sessions</div>
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
+12 -6
View File
@@ -17,6 +17,7 @@ interface GitToolbarProps {
repoId: string;
currentBranch: string;
branches: string[];
hasRemote: boolean;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
@@ -26,6 +27,7 @@ export const GitToolbar = ({
repoId,
currentBranch,
branches,
hasRemote,
onBranchChange,
onRefresh,
}: GitToolbarProps) => {
@@ -55,6 +57,7 @@ export const GitToolbar = ({
}, [loadStatus]);
const handleFetch = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await fetchRepository(projectId, repoId);
@@ -67,9 +70,10 @@ export const GitToolbar = ({
};
const handlePull = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await pullRepository(projectId, repoId, currentBranch);
await pullRepository(projectId, repoId, currentBranch || undefined);
await loadStatus();
onRefresh();
} catch {
@@ -108,7 +112,7 @@ export const GitToolbar = ({
if (!newBranchName.trim()) return;
setLoading(true);
try {
await createBranch(projectId, repoId, newBranchName, newBranchBase || "HEAD");
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
setShowNewBranch(false);
setNewBranchName("");
setNewBranchBase("");
@@ -127,6 +131,8 @@ export const GitToolbar = ({
status.untracked.length > 0
);
const canSync = hasRemote;
return (
<div className="git-toolbar">
{error && <div className="toolbar-error">{error}</div>}
@@ -162,10 +168,10 @@ export const GitToolbar = ({
</div>
<div className="toolbar-group">
<button
<button
className="toolbar-button"
onClick={handleFetch}
disabled={loading}
disabled={loading || !canSync}
type="button"
>
<Icon name="fetch" size="sm" /> Fetch
@@ -173,7 +179,7 @@ export const GitToolbar = ({
<button
className="toolbar-button"
onClick={handlePull}
disabled={loading}
disabled={loading || !canSync}
type="button"
>
<Icon name="pull" size="sm" /> Pull
@@ -182,7 +188,7 @@ export const GitToolbar = ({
<button
className="toolbar-button"
onClick={handlePush}
disabled={loading || !status?.ahead}
disabled={loading || !canSync || !status?.ahead}
type="button"
>
<Icon name="push" size="sm" /> Push
+7 -1
View File
@@ -32,6 +32,8 @@ import {
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
} from "@phosphor-icons/react";
export type IconName =
@@ -71,7 +73,9 @@ export type IconName =
| "binary"
| "external"
| "play"
| "stop";
| "stop"
| "terminal"
| "arrow-left";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
@@ -111,6 +115,8 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
};
export interface IconProps {
+126 -16
View File
@@ -1,16 +1,21 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "./icon";
import type { ToolInstance } from "../api/sessions";
import {
checkInstanceHealth,
createInstance,
deleteInstance,
listInstances,
recreateInstanceTunnel,
restartInstance,
startInstance,
stopInstance,
} from "../api/sessions";
import type { ToolType } from "../api/tool_types";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps {
projectId: string;
repoId: string;
@@ -18,12 +23,19 @@ interface InstanceListProps {
}
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState<string | null>(null);
// Stop confirmation
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
// Health check state
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
const loadInstances = useCallback(async () => {
setLoading(true);
@@ -41,6 +53,36 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
void loadInstances();
}, [loadInstances]);
// Health check polling
useEffect(() => {
const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http"));
if (runningInstances.length === 0) return;
const checkHealth = async () => {
for (const instance of runningInstances) {
try {
const health = await checkInstanceHealth(projectId, repoId, instance.id);
setHealthStatus(prev => ({
...prev,
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() }
}));
} catch {
setHealthStatus(prev => ({
...prev,
[instance.id]: { healthy: false, lastCheck: Date.now() }
}));
}
}
};
// Check immediately
void checkHealth();
// Then every 30 seconds
const interval = setInterval(() => void checkHealth(), 30000);
return () => clearInterval(interval);
}, [instances, projectId, repoId]);
const handleCreate = async () => {
if (!selectedToolType) return;
setError(null);
@@ -67,6 +109,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
const handleStop = async (instanceId: string) => {
try {
await stopInstance(projectId, repoId, instanceId);
setStopConfirmId(null);
await loadInstances();
} catch {
setError("Failed to stop instance");
@@ -86,12 +129,22 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
if (!confirm("Are you sure you want to delete this instance?")) return;
try {
await deleteInstance(projectId, repoId, instanceId);
await loadInstances();
// Update state immediately instead of reloading
setInstances(prev => prev.filter(i => i.id !== instanceId));
} catch {
setError("Failed to delete instance");
}
};
const handleRecreateTunnel = async (instanceId: string) => {
try {
await recreateInstanceTunnel(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to recreate tunnel");
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "running":
@@ -106,6 +159,14 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
}
};
const isTunnelUnhealthy = (instance: ToolInstance) => {
if (instance.status !== "running") return false;
if (!instance.url?.startsWith("http")) return false;
const health = healthStatus[instance.id];
if (!health) return false;
return !health.healthy;
};
return (
<div className="instance-list">
<div className="instance-list-header">
@@ -140,19 +201,48 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
style={{ backgroundColor: getStatusColor(instance.status) }}
/>
{instance.status}
{isTunnelUnhealthy(instance) && (
<span className="error-badge" title="Tunnel unreachable">
<Icon name="warning" size="sm" />
tunnel error
</span>
)}
</div>
</div>
<div className="instance-actions">
{instance.status === "running" && instance.url && (
<a
href={instance.url}
target="_blank"
rel="noopener noreferrer"
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
<>
<a
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
{isTunnelUnhealthy(instance) && (
<button
className="secondary-button small warning"
onClick={() => void handleRecreateTunnel(instance.id)}
type="button"
title="Recreate tunnel"
>
<Icon name="refresh" size="sm" />
Fix Tunnel
</button>
)}
</>
)}
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
type="button"
>
<Icon name="external" size="sm" />
Open
</a>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<button
@@ -166,13 +256,33 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
)}
{instance.status === "running" && (
<>
<button
className="ghost-button small"
onClick={() => void handleStop(instance.id)}
type="button"
>
<Icon name="stop" size="sm" />
</button>
{stopConfirmId === instance.id ? (
<div className="inline-confirm">
<span>Stop?</span>
<button
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
>
Yes
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
>
No
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
>
<Icon name="stop" size="sm" />
</button>
)}
<button
className="ghost-button small"
onClick={() => void handleRestart(instance.id)}
@@ -0,0 +1,114 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
import * as gitRepositoriesApi from "../api/git_repositories";
const mockRepositories = [
{
id: "repo-1",
name: "Main Repo",
path: "/repos/main",
project_id: "proj-1",
owner_id: "user-1",
is_mirror: false,
remote_url: null,
last_push: null,
created_at: null,
},
];
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
return {
...actual,
useParams: () => ({ projectId: "proj-1" }),
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("RepositoriesSettingsTab", () => {
it("opens create dialog and clones an existing repository", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
target: { value: "alice" },
});
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
target: { value: "demo" },
});
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "git@git.commumedia.org:alice/demo.git",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("uses advanced url fallback when requested", async () => {
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i }));
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), {
target: { value: "https://github.com/user/repo.git" },
});
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith("proj-1", {
name: "New Repo",
remote_url: "https://github.com/user/repo.git",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("shows validation when cloning without a remote url", async () => {
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
render(<RepositoriesSettingsTab />);
await waitFor(() => {
expect(screen.getByText("Main Repo")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
target: { value: "New Repo" },
});
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument();
});
});
@@ -1,37 +1,45 @@
import React, { useState, useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { apiClient } from "../api/client";
import { GitRepository } from "../api/git_repositories";
import { deleteRepository, listRepositories, type GitRepository } from "../api/git_repositories";
import { RepositoryCreateDialog } from "./repository-create-dialog";
import { Icon } from "./icon";
export const RepositoriesSettingsTab: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(">");
const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const fetchRepositories = async () => {
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories`
);
setRepositories(response.data);
} catch (err) {
setError("Failed to load repositories");
} finally {
setLoading(false);
}
};
const loadRepositories = useCallback(async () => {
if (!projectId) {
setLoading(false);
return;
}
fetchRepositories();
setLoading(true);
try {
const data = await listRepositories(projectId);
setRepositories(data);
} catch {
setError("Failed to load repositories");
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
void loadRepositories();
}, [loadRepositories]);
const handleDelete = async (repoId: string) => {
if (!projectId) return;
if (!window.confirm("Are you sure you want to delete this repository?")) return;
try {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
setRepositories(repositories.filter((r) => r.id !== repoId));
} catch (err) {
await deleteRepository(projectId, repoId);
setRepositories((current) => current.filter((r) => r.id !== repoId));
} catch {
setError("Failed to delete repository");
}
};
@@ -40,7 +48,17 @@ export const RepositoriesSettingsTab: React.FC = () => {
return (
<div className="repositories-settings-tab">
<h2>Repositories</h2>
<div className="page-header">
<h2>Repositories</h2>
<button
className="primary-button"
onClick={() => setShowCreate(true)}
type="button"
>
<Icon name="add" size="sm" />
Add Repository
</button>
</div>
{error && <div className="error-message">{error}</div>}
<div className="repositories-list">
@@ -66,6 +84,16 @@ export const RepositoriesSettingsTab: React.FC = () => {
))
)}
</div>
{showCreate && (
<RepositoryCreateDialog
projectId={projectId!}
open={showCreate}
title="Add Repository"
onClose={() => setShowCreate(false)}
onCreated={loadRepositories}
/>
)}
</div>
);
};
@@ -0,0 +1,293 @@
import { useEffect, useRef, useState } from "react";
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories";
import { Icon } from "./icon";
type CreateMode = "clone" | "blank";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
interface RepositoryCreateDialogProps {
projectId: string;
open: boolean;
title: string;
onClose: () => void;
onCreated: () => Promise<void> | void;
}
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
const [createMode, setCreateMode] = useState<CreateMode>("clone");
const [formName, setFormName] = useState("");
const [owner, setOwner] = useState("");
const [repoName, setRepoName] = useState("");
const [advancedUrl, setAdvancedUrl] = useState("");
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus;
result: URLParseResult | null;
}>({ status: "idle", result: null });
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open && debounceTimer.current) {
clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
}, [open]);
useEffect(() => {
if (!open) return;
if (!useAdvancedUrl) {
setUrlValidation({ status: "idle", result: null });
return;
}
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
if (!advancedUrl.trim()) {
setUrlValidation({ status: "idle", result: null });
return;
}
setUrlValidation({ status: "validating", result: null });
debounceTimer.current = setTimeout(async () => {
try {
const result = await parseGitUrl(advancedUrl.trim());
if (result.is_valid_clone_url) {
setUrlValidation({ status: "valid", result });
} else if (result.needs_parsing) {
setUrlValidation({ status: "needs-parsing", result });
} else {
setUrlValidation({ status: "invalid", result });
}
} catch {
setUrlValidation({ status: "invalid", result: null });
}
}, 300);
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [advancedUrl, open, useAdvancedUrl]);
const resetForm = () => {
setCreateMode("clone");
setFormName("");
setOwner("");
setRepoName("");
setAdvancedUrl("");
setUseAdvancedUrl(false);
setFormError(null);
setUrlValidation({ status: "idle", result: null });
};
const handleClose = () => {
resetForm();
onClose();
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Repository name is required");
return;
}
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: undefined,
};
if (createMode === "clone") {
if (useAdvancedUrl) {
if (!advancedUrl.trim()) {
setFormError("Remote URL is required for advanced cloning");
return;
}
input.remote_url = advancedUrl.trim();
} else {
if (!owner.trim() || !repoName.trim()) {
setFormError("Owner and repository name are required");
return;
}
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
}
}
await createRepository(projectId, input);
handleClose();
await onCreated();
} catch (error: unknown) {
const response = error as { response?: { data?: { detail?: string } } };
const detail = response.response?.data?.detail;
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
}
};
const handleUseSuggestedUrl = () => {
if (urlValidation.result?.base_url) {
setAdvancedUrl(urlValidation.result.base_url);
setUrlValidation({ status: "idle", result: null });
setFormError(null);
}
};
const getUrlInputClass = () => {
switch (urlValidation.status) {
case "valid":
return "valid-url";
case "needs-parsing":
return "needs-parsing-url";
case "invalid":
return "invalid-url";
default:
return "";
}
};
if (!open) return null;
return (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h3>{title}</h3>
<p className="muted">
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
</p>
<form onSubmit={handleSubmit} className="stack">
<div className="form-field">
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "clone"}
onChange={() => setCreateMode("clone")}
/>
Clone existing repository
</label>
<label>
<input
type="radio"
name="repository-mode"
checked={createMode === "blank"}
onChange={() => setCreateMode("blank")}
/>
Create blank repository
</label>
</div>
<label className="form-field">
Repository name
<input
type="text"
value={formName}
onChange={(event) => setFormName(event.target.value)}
placeholder="repository-name"
/>
</label>
{createMode === "clone" && !useAdvancedUrl && (
<>
<label className="form-field">
Owner
<input
type="text"
value={owner}
onChange={(event) => setOwner(event.target.value)}
placeholder="owner"
/>
</label>
<label className="form-field">
Repository
<input
type="text"
value={repoName}
onChange={(event) => setRepoName(event.target.value)}
placeholder="repo-name"
/>
</label>
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
<button
type="button"
className="secondary-button small"
onClick={() => setUseAdvancedUrl(true)}
>
Use full URL instead
</button>
</>
)}
{createMode === "clone" && useAdvancedUrl && (
<label className="form-field">
Remote URL
<input
type="text"
value={advancedUrl}
onChange={(event) => setAdvancedUrl(event.target.value)}
placeholder="https://github.com/user/repo.git"
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
<button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
<button
type="button"
className="secondary-button small"
onClick={() => setUseAdvancedUrl(false)}
>
Use owner/repo instead
</button>
</label>
)}
{formError && (
<div className="error-message">
<p className="error-text">{formError}</p>
</div>
)}
<div className="dialog-actions">
<button className="secondary-button" onClick={handleClose} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
</button>
</div>
</form>
</div>
</div>
);
};
+158
View File
@@ -0,0 +1,158 @@
import React, { useEffect, useRef, useState } from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css";
interface TerminalProps {
instanceId: string;
onClose?: () => void;
}
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
const terminalRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
"connecting",
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!terminalRef.current) return;
// Initialize terminal
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
selectionBackground: "#264f78",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(terminalRef.current);
fitAddon.fit();
// Build WebSocket URL
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
// Connect WebSocket
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
};
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
event.data.arrayBuffer().then((buffer) => {
const data = new Uint8Array(buffer);
term.write(data);
});
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
}
} catch {
term.write(event.data);
}
}
};
ws.onclose = (event) => {
setStatus("disconnected");
if (event.code !== 1000) {
setError(`Connection closed (code: ${event.code})`);
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
// Handle terminal input
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Handle resize
const handleResize = () => {
fitAddon.fit();
const { cols, rows } = term;
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
);
}
};
window.addEventListener("resize", handleResize);
// Initial resize
setTimeout(handleResize, 100);
return () => {
window.removeEventListener("resize", handleResize);
ws.close();
term.dispose();
};
}, [instanceId]);
return (
<div className="terminal-wrapper">
<div className="terminal-header">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">{status}</span>
</div>
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
</button>
)}
</div>
{error && <div className="terminal-error">{error}</div>}
<div ref={terminalRef} className="terminal-container" />
</div>
);
};
+57 -30
View File
@@ -1,54 +1,81 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "./dashboard";
import { HomePage } from "./dashboard";
const mockGet = vi.fn();
const mockDashboard = vi.fn();
const mockSessions = vi.fn();
const mockProjects = vi.fn();
const mockRepos = vi.fn();
vi.mock("../api/dashboard", () => ({
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
}));
describe("DashboardPage", () => {
vi.mock("../api/sessions", () => ({
getUserSessions: (...args: unknown[]) => mockSessions(...args),
createInstance: vi.fn(),
startInstance: vi.fn(),
stopInstance: vi.fn(),
deleteInstance: vi.fn(),
recreateInstanceTunnel: vi.fn()
}));
vi.mock("../api/projects", () => ({
listProjects: (...args: unknown[]) => mockProjects(...args)
}));
vi.mock("../api/git_repositories", () => ({
listRepositories: (...args: unknown[]) => mockRepos(...args)
}));
vi.mock("../api/tool_types", () => ({
listToolTypes: vi.fn().mockResolvedValue([])
}));
describe("HomePage", () => {
beforeEach(() => {
mockGet.mockReset();
mockDashboard.mockReset();
mockSessions.mockReset();
mockProjects.mockReset();
mockRepos.mockReset();
});
it("shows loading then empty state when summary has no data", async () => {
mockGet.mockResolvedValue({
projects: 0,
repositories: 0,
sshKeys: 0,
recentActivity: []
});
it("shows overview sections", async () => {
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
mockSessions.mockResolvedValue([]);
mockProjects.mockResolvedValue([]);
mockRepos.mockResolvedValue([]);
render(<DashboardPage />);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("No activity yet")).toBeInTheDocument();
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
expect(screen.getByText("Available projects")).toBeInTheDocument();
});
});
it("shows retry action when summary request fails", async () => {
mockGet.mockRejectedValueOnce(new Error("failed"));
mockGet.mockResolvedValueOnce({
projects: 2,
repositories: 5,
sshKeys: 1,
recentActivity: ["Created repo"]
});
it("shows retry action when home load fails", async () => {
mockDashboard.mockRejectedValueOnce(new Error("failed"));
mockSessions.mockRejectedValueOnce(new Error("failed"));
mockProjects.mockRejectedValueOnce(new Error("failed"));
render(<DashboardPage />);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(screen.getByText("2")).toBeInTheDocument();
});
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
});
});
+299 -44
View File
@@ -1,83 +1,338 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings";
import type { Project } from "../types";
import { Icon } from "../components/icon";
const CARDS = [
type HomeStatus = "loading" | "ready" | "error";
const summaryCards = [
{ label: "Open sessions", key: "openSessions" },
{ label: "Projects", key: "projects" },
{ label: "Repositories", key: "repositories" },
{ label: "SSH Keys", key: "sshKeys" }
] as const;
type DashboardStatus = "loading" | "ready" | "error";
type SessionView = SessionApi;
export const DashboardPage = () => {
const [status, setStatus] = useState<DashboardStatus>("loading");
export const HomePage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<HomeStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [sessions, setSessions] = useState<SessionView[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
const [actionBusy, setActionBusy] = useState<string | null>(null);
const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadSummary = useCallback(async () => {
const loadHome = useCallback(async () => {
setStatus("loading");
try {
const data = await getDashboardSummary();
setSummary(data);
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
getDashboardSummary(),
getUserSessions(),
listProjects(),
listToolTypes(),
]);
setSummary(dashboard);
setSessions(sessionData as SessionView[]);
setProjects(projectData);
setToolTypes(toolTypeData);
setStatus("ready");
} catch {
setSummary(null);
setStatus("error");
}
}, []);
useEffect(() => {
void loadSummary();
}, [loadSummary]);
void loadHome();
}, [loadHome]);
const cards = useMemo(() => CARDS, []);
const isEmpty =
status === "ready" &&
summary !== null &&
summary.projects === 0 &&
summary.repositories === 0 &&
summary.sshKeys === 0 &&
summary.recentActivity.length === 0;
useEffect(() => {
if (!selectedProject) {
setRepositories([]);
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepositories(data);
} catch {
setRepositories([]);
}
};
void loadRepos();
}, [selectedProject]);
const activeSessions = useMemo(
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
[safeSessions]
);
const recentSessions = useMemo(
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
[safeSessions]
);
const handleCreate = async (event: React.FormEvent) => {
event.preventDefault();
if (!selectedProject || !selectedRepo || !selectedToolType) return;
setSaveState("saving");
try {
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setDisplayName("");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setSaveState("idle");
await loadHome();
} catch {
setSaveState("error");
}
};
const handleOpen = (session: SessionView) => {
if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer");
return;
}
if (session.tool_type_interfaces.includes("terminal")) {
navigate(`/instances/${session.id}/terminal`);
return;
}
navigate(`/projects/${session.project_id}`);
};
const handleStop = async (session: SessionView) => {
setActionBusy(session.id);
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleDelete = async (session: SessionView) => {
setActionBusy(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleRecreateTunnel = async (session: SessionView) => {
setActionBusy(session.id);
try {
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
return (
<section className="stack">
<h1>Dashboard</h1>
<p className="muted">Your workspace overview will appear here.</p>
<section className="stack home-page">
<header className="home-hero card">
<div className="stack-sm">
<p className="eyebrow">Workspace overview</p>
<h1>Home</h1>
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
</div>
<div className="home-hero-actions">
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
</div>
</header>
{status === "loading" && <p className="muted">Loading dashboard...</p>}
{status === "loading" && <p className="muted">Loading overview...</p>}
{status === "error" && (
<div className="card stack">
<p>Dashboard is unavailable</p>
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
<p>Unable to load your workspace overview.</p>
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
<div className="card-grid">
{cards.map((card) => (
<article className="card" key={card.label}>
<p className="card-label">{card.label}</p>
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
</article>
))}
</div>
{status === "ready" && summary && (
<>
<div className="home-summary-grid">
{summaryCards.map((card) => (
<article className="card home-summary-card" key={card.label}>
<p className="card-label">{card.label}</p>
<p className="card-value">
{card.key === "openSessions"
? activeSessions.length
: card.key === "projects"
? summary.projects
: summary.repositories}
</p>
</article>
))}
</div>
{isEmpty && <p className="muted">No activity yet</p>}
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Open sessions</p>
<h2>{activeSessions.length}</h2>
</div>
</div>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions right now.</p>
) : (
<div className="home-session-grid">
{activeSessions.map((session) => (
<article className="card session-card" key={session.id}>
<div className="stack-sm">
<div className="row row-tight">
<h3>{session.display_name}</h3>
<span className={`status-badge ${session.status}`}>{session.status}</span>
</div>
<p className="muted">{session.project_name} · {session.repository_name}</p>
<p className="muted">{session.tool_type_name}</p>
</div>
<div className="session-actions">
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
<Icon name="external" size="sm" />
Open
</button>
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
<Icon name="refresh" size="sm" />
Tunnel
</button>
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
<Icon name="stop" size="sm" />
Stop
</button>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
</article>
))}
</div>
)}
</section>
<div className="quick-actions">
<button className="primary-button" type="button">
<Icon name="add" size="sm" />
New Project
</button>
<button className="secondary-button" type="button">
<Icon name="add" size="sm" />
Add Repository
</button>
</div>
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Available projects</p>
<h2>{projects.length}</h2>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
</div>
{projects.length === 0 ? (
<p className="muted">No projects yet.</p>
) : (
<div className="home-project-grid">
{projects.map((project) => (
<article className="card project-card home-project-card" key={project.id}>
<div className="stack-sm">
<h3>{project.name}</h3>
{project.description && <p className="muted">{project.description}</p>}
</div>
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
Open Workspace
</button>
</article>
))}
</div>
)}
</section>
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Quick create</p>
<h2>Start a session</h2>
</div>
</div>
<form className="stack create-session-form" onSubmit={handleCreate}>
<div className="form-row">
<label className="form-field">
Project
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
<option value="">Select project...</option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</label>
<label className="form-field">
Repository
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
<option value="">Select repository...</option>
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
</select>
</label>
<label className="form-field">
Tool type
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
<option value="">Select tool...</option>
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
</select>
</label>
</div>
<label className="form-field">
Display name
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
</label>
<div className="form-actions">
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
</button>
{saveState === "error" && <span className="error-text">Failed to create session</span>}
</div>
</form>
</section>
{recentSessions.length > 0 && (
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Recent sessions</p>
<h2>{recentSessions.length}</h2>
</div>
</div>
<div className="recent-sessions-list">
{recentSessions.map((session) => (
<article className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span>
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
</div>
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
</article>
))}
</div>
</section>
)}
</>
)}
</section>
);
};
export { HomePage as DashboardPage };
+9 -184
View File
@@ -1,19 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
createRepository,
deleteRepository,
listRepositories,
parseGitUrl,
type GitRepositoryCreate,
type URLParseResult,
} from "../api/git_repositories";
import type { GitRepository } from "../api/git_repositories";
import { Icon } from "../components/icon";
import { RepositoryCreateDialog } from "../components/repository-create-dialog";
type RepoStatus = "loading" | "ready" | "error";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
export const GitRepositoriesPage = () => {
const { projectId } = useParams<{ projectId: string }>();
@@ -21,19 +17,8 @@ export const GitRepositoriesPage = () => {
const [status, setStatus] = useState<RepoStatus>("loading");
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [formName, setFormName] = useState("");
const [formRemoteUrl, setFormRemoteUrl] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// URL validation state
const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus;
result: URLParseResult | null;
}>({ status: "idle", result: null });
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadRepositories = useCallback(async () => {
if (!projectId) return;
setStatus("loading");
@@ -51,98 +36,6 @@ export const GitRepositoriesPage = () => {
void loadRepositories();
}, [loadRepositories]);
// Validate URL with debounce
useEffect(() => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
if (!formRemoteUrl.trim()) {
setUrlValidation({ status: "idle", result: null });
return;
}
setUrlValidation({ status: "validating", result: null });
debounceTimer.current = setTimeout(async () => {
try {
const result = await parseGitUrl(formRemoteUrl.trim());
if (result.is_valid_clone_url) {
setUrlValidation({ status: "valid", result });
} else if (result.needs_parsing) {
setUrlValidation({ status: "needs-parsing", result });
} else {
setUrlValidation({ status: "invalid", result });
}
} catch {
setUrlValidation({ status: "invalid", result: null });
}
}, 300);
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [formRemoteUrl]);
const getUrlInputClass = () => {
switch (urlValidation.status) {
case "valid":
return "valid-url";
case "needs-parsing":
return "needs-parsing-url";
case "invalid":
return "invalid-url";
default:
return "";
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Repository name is required");
return;
}
if (!projectId) return;
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: formRemoteUrl.trim() || undefined,
};
await createRepository(projectId, input);
setShowCreate(false);
setFormName("");
setFormRemoteUrl("");
setUrlValidation({ status: "idle", result: null });
await loadRepositories();
} catch (err: unknown) {
const axiosError = err as { response?: { status: number; data: { detail: { suggested_url: string; message: string } } } };
if (axiosError.response?.status === 422 && axiosError.response?.data?.detail?.suggested_url) {
// Show URL correction suggestion
const detail = axiosError.response.data.detail;
setFormError(
`${detail.message}\nSuggested: ${detail.suggested_url}`
);
} else {
setFormError("Failed to create repository");
}
}
};
const handleUseSuggestedUrl = () => {
if (urlValidation.result?.base_url) {
setFormRemoteUrl(urlValidation.result.base_url);
setUrlValidation({ status: "idle", result: null });
setFormError(null);
}
};
const handleDelete = async (repoId: string) => {
if (!projectId) return;
try {
@@ -233,81 +126,13 @@ export const GitRepositoriesPage = () => {
)}
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Create Repository</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="repository-name"
/>
</label>
<label className="form-field">
Remote URL (optional)
<input
type="text"
value={formRemoteUrl}
onChange={(e) => setFormRemoteUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
className={getUrlInputClass()}
/>
{urlValidation.status === "validating" && (
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
<Icon name="warning" size="sm" /> This looks like a browser URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">
Suggested: {urlValidation.result.base_url}
</span>
<button
type="button"
className="secondary-button small"
onClick={handleUseSuggestedUrl}
>
Use Suggested
</button>
</div>
</div>
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
</label>
{formError && (
<div className="error-message">
{formError.split("\n").map((line, i) => (
<p key={i} className="error-text">{line}</p>
))}
</div>
)}
<div className="dialog-actions">
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
Create
</button>
</div>
</form>
</div>
</div>
<RepositoryCreateDialog
projectId={projectId!}
open={showCreate}
title="Create Repository"
onClose={() => setShowCreate(false)}
onCreated={loadRepositories}
/>
)}
</section>
);
+41 -8
View File
@@ -1,4 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./projects";
@@ -29,13 +30,21 @@ afterEach(() => {
describe("ProjectsPage", () => {
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -46,7 +55,11 @@ describe("ProjectsPage", () => {
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -55,7 +68,11 @@ describe("ProjectsPage", () => {
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
@@ -67,7 +84,11 @@ describe("ProjectsPage", () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -96,7 +117,11 @@ describe("ProjectsPage", () => {
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -112,7 +137,11 @@ describe("ProjectsPage", () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -141,7 +170,11 @@ describe("ProjectsPage", () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
render(<ProjectsPage />);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
+4 -1
View File
@@ -196,6 +196,7 @@ export const RepoWorkspace = () => {
repoId={selectedRepoId}
currentBranch={currentBranch}
branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)}
onBranchChange={(branch) => {
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
@@ -364,6 +365,9 @@ const FileBrowser = ({
<Icon name="folder" size="sm" /> ..
</button>
)}
{entries.length === 0 && (
<p className="muted">No files in this repository yet.</p>
)}
{entries.map((entry) => {
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
return (
@@ -388,4 +392,3 @@ const FileBrowser = ({
</div>
);
};
+603
View File
@@ -0,0 +1,603 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listProjects } from "../api/projects";
import type { Project } from "../types";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import {
getUserSessions,
type Session,
deleteInstance,
stopInstance,
startInstance,
checkInstanceHealth,
recreateInstanceTunnel,
} from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { createInstance } from "../api/sessions";
import { getUserConfig, updateUserConfig } from "../api/settings";
import { Icon } from "../components/icon";
type SessionsStatus = "loading" | "ready" | "error";
type CreateStatus = "idle" | "creating" | "error";
export const SessionsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState<string>("");
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [displayName, setDisplayName] = useState("");
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
const [createError, setCreateError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, { healthy: boolean; status_code: number | null; error?: string }>>({});
const [recreatingId, setRecreatingId] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setStatus("loading");
try {
const [sessionsData, config] = await Promise.all([
getUserSessions(),
getUserConfig(),
]);
setSessions(sessionsData);
setLastSessionId(config.last_session_id ?? null);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadSessions();
}, [loadSessions]);
useEffect(() => {
const loadProjects = async () => {
try {
const data = await listProjects();
setProjects(data);
} catch {
// ignore
}
};
void loadProjects();
}, []);
useEffect(() => {
const loadToolTypes = async () => {
try {
const data = await listToolTypes();
setToolTypes(data);
} catch {
// ignore
}
};
void loadToolTypes();
}, []);
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
const checkHealth = async () => {
const runningSessions = sessions.filter(
(s) => s.status === "running" && s.url
);
for (const session of runningSessions) {
try {
const health = await checkInstanceHealth(
session.project_id,
session.repository_id,
session.id
);
setTunnelHealth((prev) => ({
...prev,
[session.id]: health,
}));
} catch {
setTunnelHealth((prev) => ({
...prev,
[session.id]: { healthy: false, status_code: null, error: "check failed" },
}));
}
}
};
// Check immediately and then every 30 seconds
void checkHealth();
const interval = setInterval(() => void checkHealth(), 30000);
return () => clearInterval(interval);
}, [sessions]);
useEffect(() => {
if (!selectedProject) {
setRepositories([]);
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepositories(data);
} catch {
setRepositories([]);
}
};
void loadRepos();
}, [selectedProject]);
const activeSessions = useMemo(
() => sessions.filter((s) => ["running", "building", "pending"].includes(s.status)),
[sessions]
);
const recentSessions = useMemo(
() => sessions.filter((s) => ["stopped", "error"].includes(s.status)).slice(0, 5),
[sessions]
);
const lastSession = useMemo(
() => sessions.find((s) => s.id === lastSessionId) ?? null,
[sessions, lastSessionId]
);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
if (!selectedProject || !selectedRepo || !selectedToolType) {
setCreateError("Project, repository, and tool type are required");
return;
}
setCreateStatus("creating");
try {
const instance = await createInstance(
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined
);
// Auto-start the instance
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setCreateStatus("idle");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setDisplayName("");
await loadSessions();
} catch {
setCreateStatus("error");
setCreateError("Failed to create session");
}
};
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
try {
await stopInstance(projectId, repoId, sessionId);
setStopConfirmId(null);
await loadSessions();
} catch {
setStopConfirmId(null);
}
};
const handleDelete = async (sessionId: string, projectId: string, repoId: string) => {
try {
await deleteInstance(projectId, repoId, sessionId);
setDeleteConfirmId(null);
// Remove from local state immediately
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
} catch {
setDeleteConfirmId(null);
}
};
const handleRecreateTunnel = async (session: Session) => {
setRecreatingId(session.id);
try {
await recreateInstanceTunnel(
session.project_id,
session.repository_id,
session.id
);
// Refresh sessions to get new URL
await loadSessions();
} catch {
// ignore
} finally {
setRecreatingId(null);
}
};
const handleOpen = (session: Session) => {
if (session.url) {
window.open(session.url, '_blank', 'noopener,noreferrer');
} else if (session.tool_type_interfaces?.includes("terminal")) {
navigate(`/instances/${session.id}/terminal`);
} else {
navigate(`/projects/${session.project_id}`);
}
};
const handleResumeLast = async () => {
if (!lastSession) return;
// Find the project and repo IDs
const project = projects.find((p) => p.name === lastSession.project_name);
if (project) {
navigate(`/projects/${project.id}`);
}
};
return (
<section className="stack sessions-page">
<div className="page-header">
<h1>Sessions</h1>
</div>
{status === "loading" && <p className="muted">Loading sessions...</p>}
{status === "error" && (
<div className="card stack">
<p>Failed to load sessions</p>
<button className="secondary-button" onClick={() => void loadSessions()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "ready" && (
<>
{/* Last Session */}
{lastSession && (
<div className="last-session-section">
<h2>Last Session</h2>
<div className="card last-session-card">
<div className="last-session-info">
<h3>{lastSession.display_name}</h3>
<p className="muted">
{lastSession.tool_type_name} · {lastSession.project_name} · {lastSession.repository_name}
</p>
{lastSession.url && (
<p className="session-url">
<a href={lastSession.url} target="_blank" rel="noopener noreferrer">
{lastSession.url}
</a>
</p>
)}
<span className={`status-badge ${lastSession.status}`}>{lastSession.status}</span>
</div>
<div className="last-session-actions">
{lastSession.url ? (
<a
href={lastSession.url}
target="_blank"
rel="noopener noreferrer"
className="primary-button"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button className="primary-button" onClick={handleResumeLast} type="button">
<Icon name="play" size="sm" />
Resume
</button>
)}
</div>
</div>
</div>
)}
{/* Active Sessions */}
<div className="active-sessions-section">
<h2>
Active Sessions
{activeSessions.length > 0 && (
<span className="badge">{activeSessions.length}</span>
)}
</h2>
{activeSessions.length === 0 ? (
<p className="muted">No active sessions</p>
) : (
<div className="sessions-grid">
{activeSessions.map((session) => (
<div className="card session-card" key={session.id}>
<div className="session-info">
<h4>{session.display_name}</h4>
<p className="muted">
{session.tool_type_name} · {session.project_name}
</p>
{session.url && (
<p className="session-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
{session.url}
</a>
</p>
)}
<span className={`status-badge ${session.status}`}>{session.status}</span>
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
<span className="status-badge error">tunnel error</span>
)}
</div>
<div className="session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button
className="secondary-button small"
onClick={() => handleOpen(session)}
type="button"
>
<Icon name="external" size="sm" />
Open
</button>
)}
{tunnelHealth[session.id] && !tunnelHealth[session.id].healthy && (
<button
className="secondary-button small"
onClick={() => void handleRecreateTunnel(session)}
type="button"
disabled={recreatingId === session.id}
>
<Icon name="refresh" size="sm" />
{recreatingId === session.id ? "Recreating..." : "Recreate Tunnel"}
</button>
)}
{stopConfirmId === session.id ? (
<div className="stop-confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={() =>
void handleStop(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Stop
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => setStopConfirmId(session.id)}
type="button"
>
<Icon name="stop" size="sm" />
Stop
</button>
)}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<button
className="danger-button small"
onClick={() =>
void handleDelete(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={() => setDeleteConfirmId(session.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Recent Sessions */}
{recentSessions.length > 0 && (
<div className="recent-sessions-section">
<h2>Recent Sessions</h2>
<div className="recent-sessions-list">
{recentSessions.map((session) => (
<div className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span>
<span className="muted">
{session.tool_type_name} · {session.project_name}
</span>
</div>
<div className="recent-session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="ghost-button small"
>
Open
</a>
) : (
<button
className="ghost-button small"
onClick={() => handleOpen(session)}
type="button"
>
Open
</button>
)}
{deleteConfirmId === session.id ? (
<div className="delete-confirm-inline">
<button
className="danger-button small"
onClick={() =>
void handleDelete(
session.id,
session.project_id,
session.repository_id
)
}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={() => setDeleteConfirmId(session.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Create Session */}
<div className="create-session-section">
<h2>Create New Session</h2>
<form onSubmit={handleCreate} className="card stack create-session-form">
<div className="form-row">
<label className="form-field">
Project
<select
value={selectedProject}
onChange={(e) => {
setSelectedProject(e.target.value);
setSelectedRepo("");
}}
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="form-field">
Repository
<select
value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)}
disabled={!selectedProject}
>
<option value="">Select repository...</option>
{repositories.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select tool...</option>
{toolTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.display_name}
</option>
))}
</select>
</label>
</div>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
{createError && <p className="error-text">{createError}</p>}
<div className="form-actions">
<button
className="primary-button"
type="submit"
disabled={createStatus === "creating"}
>
{createStatus === "creating" ? (
<>
<Icon name="loading" size="sm" />
Creating...
</>
) : (
<>
<Icon name="add" size="sm" />
Create Session
</>
)}
</button>
</div>
</form>
</div>
</>
)}
</section>
);
};
+86 -74
View File
@@ -1,23 +1,40 @@
import { useCallback, useEffect, useState } from "react";
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { Icon } from "../components/icon";
type SettingsStatus = "loading" | "ready" | "error";
const TABS = [
{ label: "General", path: "general" },
{ label: "SSH Keys", path: "ssh-keys" },
{ label: "Tool Types", path: "tool-types" },
{ label: "Tool Configs", path: "tool-configs" },
] as const;
const THEME_OPTIONS = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
type SettingsOutletContext = {
config: UserConfig;
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
handleSave: () => Promise<void>;
saveStatus: "idle" | "saving" | "saved" | "error";
};
export const SettingsPage = () => {
const location = useLocation();
const [status, setStatus] = useState<SettingsStatus>("loading");
const [config, setConfig] = useState<UserConfig>({
theme: "system",
default_editor: null,
git_user_name: null,
git_user_email: null,
last_session_id: null,
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
@@ -45,14 +62,19 @@ export const SettingsPage = () => {
try {
const update: UserConfigUpdate = {
theme: config.theme,
default_editor: config.default_editor ?? undefined,
git_user_name: config.git_user_name ?? undefined,
git_user_email: config.git_user_email ?? undefined,
default_editor: config.default_editor,
git_user_name: config.git_user_name,
git_user_email: config.git_user_email,
};
const updated = await updateUserConfig(update);
setConfig(updated);
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2000);
if (updated.theme === "system") {
document.documentElement.removeAttribute("data-theme");
} else {
document.documentElement.setAttribute("data-theme", updated.theme);
}
window.setTimeout(() => setSaveStatus("idle"), 2000);
} catch {
setSaveStatus("error");
}
@@ -74,81 +96,71 @@ export const SettingsPage = () => {
);
}
const parts = location.pathname.split("/").filter(Boolean);
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
return (
<section className="stack">
<div className="page-header">
<h1>Settings</h1>
</div>
<section className="stack settings-page">
<header className="settings-header card stack-sm">
<div>
<p className="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
</header>
<div className="card stack">
<h2>Appearance</h2>
<label className="form-field">
Theme
<select
value={config.theme}
onChange={(e) => handleChange("theme", e.target.value)}
<nav className="settings-tabs" aria-label="Settings sections">
{TABS.map((tab) => (
<Link
key={tab.path}
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</div>
{tab.label}
</Link>
))}
</nav>
<div className="card stack">
<h2>Git Identity</h2>
<label className="form-field">
User Name
<input
type="text"
value={config.git_user_name ?? ""}
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
placeholder="Your git commit name"
/>
</label>
<label className="form-field">
User Email
<input
type="email"
value={config.git_user_email ?? ""}
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
placeholder="your.email@example.com"
/>
</label>
</div>
<div className="card stack">
<h2>Editor</h2>
<label className="form-field">
Default Editor
<input
type="text"
value={config.default_editor ?? ""}
onChange={(e) => handleChange("default_editor", e.target.value || null)}
placeholder="e.g., vscode, vim, cursor"
/>
</label>
</div>
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save Settings
</>
)}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
<div className="settings-panel card">
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
</div>
</section>
);
};
export const GeneralSettingsTab = () => {
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
return (
<div className="stack">
<h2>General</h2>
<label className="form-field">
Theme
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</label>
<label className="form-field">
Git user name
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
</label>
<label className="form-field">
Git user email
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
</label>
<label className="form-field">
Default editor
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
</label>
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
</div>
</div>
);
};
+11 -1
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { Icon } from "../components/icon";
export const SSHKeysPage = () => {
const navigate = useNavigate();
const [keys, setKeys] = useState<SSHKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -61,7 +63,15 @@ export const SSHKeysPage = () => {
return (
<section className="stack">
<h1>SSH Keys</h1>
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>SSH Keys</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
</div>
{error && <div className="error">{error}</div>}
+38
View File
@@ -0,0 +1,38 @@
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal";
import { Icon } from "../components/icon";
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
return (
<section className="terminal-page">
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
<Icon name="arrow-left" size="sm" />
Back
</button>
<h1>Terminal</h1>
</div>
<TerminalComponent
instanceId={instanceId}
onClose={() => navigate(-1)}
/>
</section>
);
};
+354
View File
@@ -0,0 +1,354 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "../components/icon";
import { listToolTypes, type ToolType } from "../api/tool_types";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
type ToolConfig,
} from "../api/tool_configs";
type ConfigStatus = "loading" | "ready" | "error";
export const ToolConfigsPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ConfigStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [showForm, setShowForm] = useState(false);
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
const [formData, setFormData] = useState({
key: "",
value: "",
config_type: "env",
file_path: "",
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const loadData = useCallback(async () => {
try {
const [typesData, configsData] = await Promise.all([
listToolTypes(),
listToolConfigs(),
]);
setToolTypes(typesData);
setConfigs(configsData);
if (typesData.length > 0 && !selectedToolType) {
setSelectedToolType(typesData[0].id);
}
setStatus("ready");
} catch {
setStatus("error");
}
}, [selectedToolType]);
useEffect(() => {
void loadData();
}, [loadData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaveStatus("saving");
try {
const data = {
tool_type_id: selectedToolType,
key: formData.key,
value: formData.value,
config_type: formData.config_type,
file_path: formData.config_type === "file" ? formData.file_path : undefined,
};
if (editingConfig) {
await updateToolConfig(editingConfig.id, data);
} else {
await createToolConfig(data);
}
setSaveStatus("saved");
setShowForm(false);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
await loadData();
} catch {
setSaveStatus("error");
}
};
const handleEdit = (config: ToolConfig) => {
setEditingConfig(config);
setFormData({
key: config.key,
value: config.value,
config_type: config.config_type,
file_path: config.file_path || "",
});
setSelectedToolType(config.tool_type_id);
setShowForm(true);
};
const handleDelete = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
// Error handled by UI state
}
};
const filteredConfigs = configs.filter(
(c) => c.tool_type_id === selectedToolType
);
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
if (status === "loading") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<p className="muted">Loading...</p>
</section>
);
}
if (status === "error") {
return (
<section className="stack">
<div className="page-header">
<h1>Tool Configurations</h1>
</div>
<div className="card stack">
<p>Failed to load configurations</p>
<button className="secondary-button" onClick={() => void loadData()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
</section>
);
}
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Configurations</h1>
</div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
Back to settings
</button>
<p className="muted">
Manage environment variables and configuration files for your tools
</p>
</div>
{/* Tool Type Selector */}
<div className="card">
<label htmlFor="tool-type-select">Select Tool</label>
<select
id="tool-type-select"
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setShowForm(false);
setEditingConfig(null);
}}
className="form-input"
>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
{selectedTool && (
<p className="muted" style={{ marginTop: "0.5rem" }}>
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")}
</p>
)}
</div>
{/* Config List */}
<div className="card stack">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h2>Configuration Variables</h2>
<button
className="primary-button small"
onClick={() => {
setShowForm(true);
setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" });
}}
type="button"
>
<Icon name="add" size="sm" />
Add Config
</button>
</div>
{filteredConfigs.length === 0 ? (
<p className="muted">No configurations for this tool yet.</p>
) : (
<div className="stack" style={{ gap: "0.5rem" }}>
{filteredConfigs.map((config) => (
<div
key={config.id}
className="card"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 1rem",
}}
>
<div>
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}>
<code style={{ fontWeight: 600 }}>{config.key}</code>
<span
className="badge"
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)",
color: "white",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
}}
>
{config.config_type}
</span>
</div>
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}>
{config.config_type === "file" && config.file_path
? `File: ${config.file_path}`
: "Environment variable"}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => handleEdit(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => void handleDelete(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Add/Edit Form */}
{showForm && (
<div className="card stack">
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
<form onSubmit={handleSubmit} className="stack">
<div>
<label htmlFor="config-key">Key</label>
<input
id="config-key"
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div>
<label htmlFor="config-type">Type</label>
<select
id="config-type"
value={formData.config_type}
onChange={(e) =>
setFormData({ ...formData, config_type: e.target.value })
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{formData.config_type === "file" && (
<div>
<label htmlFor="config-file-path">File Path</label>
<input
id="config-file-path"
type="text"
value={formData.file_path}
onChange={(e) =>
setFormData({ ...formData, file_path: e.target.value })
}
placeholder="e.g., /app/config.json"
className="form-input"
required
/>
</div>
)}
<div>
<label htmlFor="config-value">Value</label>
<textarea
id="config-value"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
placeholder={
formData.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={formData.config_type === "file" ? 8 : 2}
required
/>
</div>
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
<button
type="button"
className="secondary-button"
onClick={() => {
setShowForm(false);
setEditingConfig(null);
}}
>
Cancel
</button>
<button type="submit" className="primary-button">
{editingConfig ? "Update" : "Add"} Config
</button>
</div>
{saveStatus === "saved" && (
<p className="text-success" style={{ textAlign: "right" }}>
Saved successfully!
</p>
)}
{saveStatus === "error" && (
<p className="text-error" style={{ textAlign: "right" }}>
Failed to save. Please try again.
</p>
)}
</form>
</div>
)}
</section>
);
};
+94 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
createToolType,
@@ -15,6 +16,7 @@ type ToolTypesStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ToolTypesPage = () => {
const navigate = useNavigate();
const [status, setStatus] = useState<ToolTypesStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
@@ -22,6 +24,9 @@ export const ToolTypesPage = () => {
const [formName, setFormName] = useState("");
const [formDisplayName, setFormDisplayName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formCategory, setFormCategory] = useState("");
const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
const [formPort, setFormPort] = useState("");
const [formTemplate, setFormTemplate] = useState("");
const [formVariables, setFormVariables] = useState("");
const [formError, setFormError] = useState<string | null>(null);
@@ -47,6 +52,9 @@ export const ToolTypesPage = () => {
setFormName("");
setFormDisplayName("");
setFormDescription("");
setFormCategory("");
setFormInterfaces([]);
setFormPort("");
setFormTemplate("");
setFormVariables("");
setFormError(null);
@@ -58,7 +66,10 @@ export const ToolTypesPage = () => {
setFormName(toolType.name);
setFormDisplayName(toolType.display_name);
setFormDescription(toolType.description ?? "");
setFormTemplate(toolType.compose_template);
setFormCategory(toolType.category ?? "");
setFormInterfaces(toolType.interfaces ?? []);
setFormPort(toolType.default_port?.toString() ?? "");
setFormTemplate(toolType.compose_template ?? "");
setFormVariables(toolType.required_variables.join(", "));
setFormError(null);
setEditingToolType(toolType);
@@ -80,6 +91,11 @@ export const ToolTypesPage = () => {
return;
}
if (!formPort.trim() || isNaN(Number(formPort))) {
setFormError("Default port is required and must be a number");
return;
}
const variables = formVariables
.split(",")
.map((v) => v.trim())
@@ -91,6 +107,9 @@ export const ToolTypesPage = () => {
name: formName.trim(),
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
default_port: Number(formPort),
compose_template: formTemplate.trim(),
required_variables: variables,
};
@@ -99,6 +118,9 @@ export const ToolTypesPage = () => {
const input: UpdateToolTypeRequest = {
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
default_port: Number(formPort),
compose_template: formTemplate.trim(),
required_variables: variables,
};
@@ -145,12 +167,18 @@ export const ToolTypesPage = () => {
return (
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h1>Tool Types</h1>
<button onClick={openCreate}>
<div className="page-header" style={{ marginBottom: "1rem" }}>
<div>
<p className="eyebrow">Settings</p>
<h1>Tool Types</h1>
</div>
<div className="row">
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
</button>
</div>
</div>
{toolTypes.length === 0 ? (
@@ -164,6 +192,13 @@ export const ToolTypesPage = () => {
{toolType.is_builtin && <span className="badge">Built-in</span>}
</div>
<p className="text-secondary">{toolType.description || "No description"}</p>
<div className="tool-type-meta">
<span>Port: {toolType.default_port || "N/A"}</span>
{toolType.interfaces?.length > 0 && (
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
)}
{toolType.category && <span>Category: {toolType.category}</span>}
</div>
<div className="card-actions">
{!toolType.is_builtin && (
<>
@@ -240,6 +275,61 @@ export const ToolTypesPage = () => {
/>
</div>
<div className="form-group">
<label>Category</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g., editor, notebook, ai-assistant"
/>
</div>
<div className="form-group">
<label>Interfaces</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("web")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "web"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "web"));
}
}}
/>
Web
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("terminal")}
onChange={(e) => {
if (e.target.checked) {
setFormInterfaces([...formInterfaces, "terminal"]);
} else {
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal"));
}
}}
/>
Terminal
</label>
</div>
</div>
<div className="form-group">
<label>Default Port *</label>
<input
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
placeholder="e.g., 8443"
required
/>
</div>
<div className="form-group">
<label>Compose Template (YAML)</label>
<textarea
+527
View File
@@ -0,0 +1,527 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ToolWorkshopPage } from "./tool-workshop";
import * as toolTypesApi from "../api/tool_types";
import * as toolConfigsApi from "../api/tool_configs";
import * as configFoldersApi from "../api/config_folders";
const mockToolTypes = [
{
id: "type-1",
name: "code-server",
display_name: "VS Code Server",
description: "VS Code in browser",
category: "editor",
interfaces: ["web"],
default_port: 8443,
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
dockerfile_template: null,
build_context: null,
readiness_probe: null,
required_variables: ["REPO_PATH"],
is_builtin: true,
created_by_id: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "type-2",
name: "custom-tool",
display_name: "Custom Tool",
description: "My custom tool",
category: "utility",
interfaces: ["terminal"],
default_port: 8080,
definition_type: "dockerfile",
compose_template: null,
dockerfile_template: "FROM python:3.11",
build_context: null,
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
required_variables: [],
is_builtin: false,
created_by_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
const mockConfigs = [
{
id: "config-1",
tool_type_id: "type-1",
project_id: null,
key: "OPENAI_API_KEY",
value: "sk-test123",
config_type: "env",
file_path: null,
port_override: null,
start_command: null,
working_directory: null,
environment_variables: {},
volumes: [],
},
{
id: "config-2",
tool_type_id: "type-2",
project_id: null,
key: "advanced-config",
value: "test-value",
config_type: "env",
file_path: null,
port_override: 9090,
start_command: "python app.py",
working_directory: "/app",
environment_variables: { DEBUG: "true" },
volumes: [{ source: "data", target: "/data", type: "bind" }],
},
];
const mockFolders = [
{
id: "folder-1",
user_id: "user-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "folder-2",
user_id: "user-1",
name: "project-configs",
description: "Project specific configs",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost:8080" },
project_overrides: {
"proj-1": {
mount_path: "/app",
files: { ".env": "API_URL=http://prod.api" },
},
},
is_active: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("ToolWorkshopPage", () => {
it("renders loading state initially", () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
render(<ToolWorkshopPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("renders tool types tab by default", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
it("switches to configs tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
expect(screen.getByText("advanced-config")).toBeInTheDocument();
});
it("switches to folders tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
expect(screen.getByText("project-configs")).toBeInTheDocument();
});
it("opens tool type creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
});
it("creates tool type with compose definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "New Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "8080" },
});
fireEvent.change(screen.getByLabelText(/compose template/i), {
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-tool",
display_name: "New Tool",
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
})
);
});
});
it("creates tool type with dockerfile definition", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "docker-tool" },
});
fireEvent.change(screen.getByLabelText("Display Name *"), {
target: { value: "Docker Tool" },
});
fireEvent.change(screen.getByLabelText("Default Port *"), {
target: { value: "3000" },
});
// Switch to dockerfile
fireEvent.change(screen.getByLabelText("Definition Type"), {
target: { value: "dockerfile" },
});
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
target: { value: "FROM python:3.11\\nRUN pip install flask" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "docker-tool",
definition_type: "dockerfile",
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
})
);
});
});
it("shows readiness probe fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
expect(screen.getByText(/interval/i)).toBeInTheDocument();
});
it("opens config creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
});
it("creates config with advanced fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
fireEvent.change(screen.getByLabelText(/key/i), {
target: { value: "MY_CONFIG" },
});
fireEvent.change(screen.getByLabelText(/value/i), {
target: { value: "my-value" },
});
fireEvent.change(screen.getByLabelText(/port override/i), {
target: { value: "9090" },
});
fireEvent.change(screen.getByLabelText(/start command/i), {
target: { value: "python app.py" },
});
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
key: "MY_CONFIG",
value: "my-value",
port_override: 9090,
start_command: "python app.py",
})
);
});
expect(configsListMock).toHaveBeenCalledTimes(2);
});
it("opens folder creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
});
it("creates config folder successfully", async () => {
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByLabelText("Name *"), {
target: { value: "new-folder" },
});
fireEvent.change(screen.getByLabelText("Mount Path *"), {
target: { value: "/home/dev" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-folder",
mount_path: "/home/dev",
})
);
});
expect(foldersListMock).toHaveBeenCalledTimes(2);
});
it("shows folder active/inactive status", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
// Check that active folder shows Active badge
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("handles error state gracefully", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("retries loading after error", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("deletes tool type successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
// Find and click delete button for custom tool (not built-in)
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
screen.getByText("Custom Tool").parentElement;
if (customToolCard) {
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
if (deleteButton) {
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(deleteButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("type-2");
});
expect(listMock).toHaveBeenCalledTimes(2);
}
}
});
});
File diff suppressed because it is too large Load Diff
+20 -6
View File
@@ -2,7 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { AppShell } from "./components/app-shell";
import { ProtectedRoute } from "./components/protected-route";
import { DashboardPage } from "./pages/dashboard";
import { HomePage } from "./pages/dashboard";
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
import { ProfilePage } from "./pages/profile";
import { ProjectsPage } from "./pages/projects";
@@ -10,14 +10,21 @@ import { GitRepositoriesPage } from "./pages/git-repositories";
import { GitHistoryPage } from "./pages/git-history";
import { ProjectSettingsPage } from "./pages/project-settings";
import { RepoWorkspace } from "./pages/repo-workspace";
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
import { TerminalPage } from "./pages/terminal";
import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys";
import { SettingsPage } from "./pages/settings";
import { ToolConfigsPage } from "./pages/tool-configs";
import { ToolTypesPage } from "./pages/tool-types";
export const AppRouter = () => {
return (
<Routes>
<Route path="/login" element={<LoginRedirectPage />} />
<Route path="/sessions" element={<Navigate to="/" replace />} />
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
<Route path="/tool-types" element={<Navigate to="/settings/tool-types" replace />} />
<Route path="/tool-configs" element={<Navigate to="/settings/tool-configs" replace />} />
<Route
path="/"
element={
@@ -26,16 +33,23 @@ export const AppRouter = () => {
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route index element={<HomePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<RepoWorkspace />} />
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="settings" element={<SettingsPage />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="tool-types" element={<ToolTypesPage />} />
<Route path="tool-configs" element={<ToolConfigsPage />} />
<Route path="*" element={<Navigate to="general" replace />} />
</Route>
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
+6 -3
View File
@@ -1,12 +1,15 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
export interface Session {
id: string;
display_name: string;
tool_type_name: string;
tool_icon: string;
tool_type_interfaces: string[];
repository_name: string;
repository_id: string;
project_name: string;
project_id: string;
status: string;
url: string | null;
}
@@ -21,9 +24,9 @@ const SessionsContext = createContext<SessionsContextType | undefined>(undefined
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
const [sessions, setSessions] = useState<Session[]>([]);
const setAllSessions = (newSessions: Session[]) => {
const setAllSessions = useCallback((newSessions: Session[]) => {
setSessions(newSessions);
};
}, []);
return (
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
+502 -18
View File
@@ -1,6 +1,6 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
font-family: "Inter", "IBM Plex Sans", "Segoe UI", sans-serif;
--bg: #f4f1ea;
--panel: #fffef9;
--ink: #1d1d1b;
@@ -8,6 +8,17 @@
--brand: #275d4b;
--brand-strong: #154236;
--border: #d8d0c5;
--primary: #275d4b;
--primary-fg: #fffef9;
--color-primary: #275d4b;
--success: #2f8f62;
--success-light: rgba(47, 143, 98, 0.14);
--warning: #c08a1e;
--warning-light: rgba(192, 138, 30, 0.14);
--danger: #b94a3c;
--danger-light: rgba(185, 74, 60, 0.14);
--info: #4f7fb8;
--info-light: rgba(79, 127, 184, 0.14);
/* Spacing Scale (4px base) */
--space-1: 0.25rem;
@@ -36,13 +47,24 @@
[data-theme="dark"] {
color-scheme: dark;
--bg: #1a1a18;
--panel: #252522;
--ink: #e8e6e1;
--muted: #a39e96;
--brand: #4a9e7f;
--brand-strong: #3d8a6e;
--border: #3d3d38;
--bg: #171613;
--panel: #22201d;
--ink: #ece7df;
--muted: #a59d92;
--brand: #5fa889;
--brand-strong: #4d9175;
--border: #39342d;
--primary: #5fa889;
--primary-fg: #171613;
--color-primary: #5fa889;
--success: #22c55e;
--success-light: rgba(34, 197, 94, 0.15);
--warning: #f59e0b;
--warning-light: rgba(245, 158, 11, 0.15);
--danger: #ef4444;
--danger-light: rgba(239, 68, 68, 0.15);
--info: #3b82f6;
--info-light: rgba(59, 130, 246, 0.15);
}
* {
@@ -76,10 +98,14 @@ a {
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.85);
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px);
}
[data-theme="dark"] .shell-header {
background: color-mix(in srgb, var(--panel) 88%, transparent);
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
@@ -103,6 +129,7 @@ a {
display: flex;
flex-direction: column;
gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent);
}
.nav-item {
@@ -121,11 +148,134 @@ a {
color: #f7fff7;
}
.nav-section-title {
margin-top: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.nav-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.shell-content {
padding: 1.25rem;
overflow-x: hidden;
}
.eyebrow {
margin: 0;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.home-page,
.settings-page {
max-width: 1240px;
}
.home-hero {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: flex-start;
}
.home-hero-actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.home-summary-grid,
.home-project-grid,
.home-session-grid {
display: grid;
gap: var(--space-4);
}
.home-summary-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.home-project-grid,
.home-session-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.home-section h2,
.settings-header h1,
.settings-panel h2 {
margin: 0;
}
.home-section h3,
.home-section p {
margin: 0;
}
.row-tight {
gap: var(--space-2);
}
.settings-tabs {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.settings-tab {
padding: 0.6rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--panel);
}
.settings-tab.active {
background: var(--brand);
color: white;
border-color: transparent;
}
.settings-panel {
padding: 1.5rem;
}
.settings-actions,
.form-actions {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.error-text {
color: var(--danger);
}
.success-text {
color: var(--success);
}
.small {
padding: 0.42rem 0.7rem;
min-height: 38px;
}
.session-card,
.project-card,
.recent-session-item {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
/* Responsive Shell */
@media (max-width: 767px) {
.shell-body {
@@ -366,11 +516,14 @@ a {
}
.form-field input,
.form-field textarea {
.form-field textarea,
.form-field select {
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: 10px;
font: inherit;
background: var(--panel);
color: var(--ink);
}
.dialog-actions {
@@ -469,18 +622,18 @@ a {
/* URL Validation Styles */
.valid-url {
border-color: #16a34a !important;
background-color: #f0fdf4 !important;
border-color: var(--success, #16a34a) !important;
background-color: var(--success-light, #f0fdf4) !important;
}
.needs-parsing-url {
border-color: #ca8a04 !important;
background-color: #fefce8 !important;
border-color: var(--warning, #ca8a04) !important;
background-color: var(--warning-light, #fefce8) !important;
}
.invalid-url {
border-color: #dc2626 !important;
background-color: #fef2f2 !important;
border-color: var(--danger, #dc2626) !important;
background-color: var(--danger-light, #fef2f2) !important;
}
.validation-status {
@@ -490,11 +643,11 @@ a {
}
.validation-status.valid {
color: #16a34a;
color: var(--success, #16a34a);
}
.validation-status.warning {
color: #ca8a04;
color: var(--warning, #ca8a04);
}
.validation-status.invalid {
@@ -2321,3 +2474,334 @@ a.nav-item,
gap: var(--space-2);
align-items: center;
}
/* ============================================
Terminal Styles
============================================ */
.terminal-page {
display: flex;
flex-direction: column;
height: 100vh;
padding: var(--space-4);
gap: var(--space-4);
}
.terminal-page-header {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
}
.terminal-page-header h1 {
margin: 0;
}
.terminal-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
}
.terminal-status {
display: flex;
align-items: center;
gap: var(--space-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.status-dot.connected {
background: #0dbc79;
}
.status-dot.disconnected,
.status-dot.error {
background: #cd3131;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.status-text {
font-size: 0.875rem;
color: #d4d4d4;
text-transform: capitalize;
}
.terminal-close {
padding: var(--space-1) var(--space-3);
background: transparent;
border: 1px solid #666;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.875rem;
}
.terminal-close:hover {
background: #3e3e3e;
}
.terminal-error {
padding: var(--space-3) var(--space-4);
background: #cd3131;
color: white;
font-size: 0.875rem;
flex-shrink: 0;
}
.terminal-container {
flex: 1;
min-height: 0;
padding: var(--space-2);
}
.terminal-container .xterm {
height: 100%;
}
.terminal-container .xterm-viewport {
background: #1e1e1e !important;
}
/* Responsive terminal */
@media (max-width: 767px) {
.terminal-page {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page-header h1 {
font-size: 1.25rem;
}
}
/* ============================================
Sessions Page Styles
============================================ */
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--primary);
color: var(--primary-fg);
border-radius: 9px;
font-size: 11px;
font-weight: 600;
margin-left: auto;
}
.sessions-page {
max-width: 1200px;
}
.last-session-section {
margin-bottom: var(--space-6);
}
.last-session-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-5);
border: 2px solid var(--primary);
}
.last-session-info h3 {
margin: 0 0 var(--space-1) 0;
font-size: 1.25rem;
}
.active-sessions-section {
margin-bottom: var(--space-6);
}
.active-sessions-section h2 {
display: flex;
align-items: center;
gap: var(--space-2);
}
.active-sessions-section .badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 6px;
background: var(--success);
color: white;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-4);
}
.session-card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
}
.session-info h4 {
margin: 0 0 var(--space-1) 0;
font-size: 1rem;
}
.session-url {
margin: var(--space-1) 0;
font-size: 0.8rem;
word-break: break-all;
}
.session-url a {
color: var(--color-primary);
text-decoration: none;
}
.session-url a:hover {
text-decoration: underline;
}
.session-actions {
display: flex;
gap: var(--space-2);
}
.recent-sessions-section {
margin-bottom: var(--space-6);
}
.recent-sessions-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.recent-session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
.recent-session-info {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.recent-session-name {
font-weight: 500;
}
.recent-session-actions {
display: flex;
gap: var(--space-2);
align-items: center;
}
.delete-confirm-inline,
.stop-confirm-inline {
display: flex;
gap: var(--space-2);
align-items: center;
}
.confirm-text {
font-size: 13px;
color: var(--muted);
font-weight: 500;
}
.create-session-section {
margin-bottom: var(--space-6);
}
.create-session-form {
max-width: 600px;
}
.create-session-form .form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4);
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.running {
background: var(--success-light, #dcfce7);
color: var(--success, #16a34a);
}
.status-badge.stopped {
background: var(--muted-bg, #f3f4f6);
color: var(--muted, #6b7280);
}
.status-badge.pending {
background: var(--warning-light, #fef3c7);
color: var(--warning, #d97706);
}
.status-badge.error {
background: var(--danger-light, #fee2e2);
color: var(--danger, #dc2626);
}
+7 -1
View File
@@ -31,6 +31,8 @@ import {
ArrowSquareOut,
Play,
Stop,
Terminal,
ArrowLeft,
} from "@phosphor-icons/react";
export type IconName =
@@ -70,7 +72,9 @@ export type IconName =
| "binary"
| "external"
| "play"
| "stop";
| "stop"
| "terminal"
| "arrow-left";
export const iconRegistry: Record<
IconName,
@@ -124,6 +128,8 @@ export const iconRegistry: Record<
external: ArrowSquareOut,
play: Play,
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
};
export const iconCategories = {
+4
View File
@@ -80,6 +80,7 @@ services:
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-24}
REPO_BASE_PATH: /data/repos
INSTANCE_BASE_PATH: /data/instances
API_DOMAIN: ${API_DOMAIN}
WEB_DOMAIN: ${WEB_DOMAIN}
AUTHENTIK_DOMAIN: ${AUTHENTIK_DOMAIN}
@@ -92,7 +93,9 @@ services:
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
volumes:
- repo_data:/data/repos
- instance_data:/data/instances
- avatar_uploads:/app/uploads
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
postgres:
condition: service_healthy
@@ -114,6 +117,7 @@ volumes:
postgres_data:
redis_data:
repo_data:
instance_data:
avatar_uploads:
networks:
+4 -1
View File
@@ -53,10 +53,12 @@ services:
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
REDIS_URL: redis://redis:6379/0
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
REPO_BASE_PATH: /data/repos
INSTANCE_BASE_PATH: /data/instances
volumes:
- repo_data:/data/repos
- instance_data:/data/instances
ports:
- "8000:8000"
depends_on:
@@ -90,6 +92,7 @@ volumes:
postgres_data:
redis_data:
repo_data:
instance_data:
networks:
backend:
@@ -0,0 +1,68 @@
# ADR-001: Session-Based Authentication with httpOnly Cookies
## Status
Accepted
## Context
The application needs an authentication mechanism for the OAuth2 flow with Authentik. We need to decide between:
1. **JWT tokens** stored in localStorage (common SPA pattern)
2. **Session cookies** with httpOnly flag
3. **JWT tokens** in httpOnly cookies
### Constraints
- Frontend and API run on different subdomains in production (e.g., `app.example.com` and `api.example.com`)
- Must support OAuth2 authorization code flow
- Must work with Traefik reverse proxy
- Must be secure against XSS attacks
## Decision
We will use **session-based authentication with HMAC-signed httpOnly cookies**.
### Implementation
1. After OAuth callback, the API creates a session token (HMAC-SHA256 signed)
2. Token is stored in an httpOnly, Secure, SameSite cookie
3. Frontend never sees or stores the token
4. Cookie is sent automatically with every request via `withCredentials: true`
5. Session is stateless - token contains user_id and expiry
## Consequences
### Positive
- **XSS protection**: Token is never exposed to JavaScript
- **Simpler frontend**: No token management, refresh logic, or storage
- **Immediate revocation**: Can invalidate sessions server-side if needed
- **Standards compliant**: Uses well-established cookie security mechanisms
- **Works across subdomains**: Cookie domain can be set to parent domain
### Negative
- **CSRF risk**: Requires CSRF protection for state-changing operations (mitigated by SameSite=Lax/Strict)
- **Less flexible**: Harder to use with non-browser clients (mitigated by API key support if needed)
- **Cookie size**: Session token adds ~100 bytes to every request
### Alternatives Considered
**JWT in localStorage**
- Pros: Simple implementation, works with any client
- Cons: Vulnerable to XSS, requires manual token refresh, complex frontend logic
- Rejected due to XSS vulnerability
**JWT in httpOnly cookies**
- Pros: XSS protection, standard JWT benefits
- Cons: Complex refresh token rotation, no easy revocation, larger token size
- Rejected in favor of simpler session cookies
## Date
2026-05-18
## Participants
- Development Team
@@ -0,0 +1,70 @@
# ADR-002: Async SQLAlchemy with PostgreSQL
## Status
Accepted
## Context
We need to choose an ORM and database for the application. The application will handle concurrent requests and potentially long-running operations (git operations, Docker commands).
### Requirements
- Support concurrent API requests without blocking
- Handle async operations (database queries + subprocess calls)
- Type safety and autocompletion
- Migration support
- Good Python ecosystem support
## Decision
We will use **SQLAlchemy 2.0 with async PostgreSQL** via `asyncpg`.
### Implementation
1. **SQLAlchemy 2.0** with `AsyncSession` and declarative models
2. **PostgreSQL** as the primary database
3. **asyncpg** as the async driver
4. **Alembic** for database migrations
## Consequences
### Positive
- **Non-blocking I/O**: Database queries don't block the event loop
- **Scalability**: Can handle many concurrent connections
- **Type safety**: SQLAlchemy 2.0 has excellent type hint support
- **Ecosystem**: Large community, extensive documentation
- **Flexibility**: Can fall back to sync operations for complex migrations
### Negative
- **Complexity**: Async SQLAlchemy has a steeper learning curve
- **Debugging**: Harder to debug async code
- **Migration limitations**: Some Alembic operations require sync connections
- **Connection pool**: Requires careful configuration
### Alternatives Considered
**Prisma ORM**
- Pros: Modern, type-safe, auto-generated client
- Cons: Less mature Python support, custom query language
- Rejected due to less mature ecosystem
**Tortoise ORM**
- Pros: Built for async, Django-like syntax
- Cons: Smaller community, fewer features
- Rejected in favor of SQLAlchemy's maturity
**Sync SQLAlchemy with threading**
- Pros: Simpler, well-understood
- Cons: Thread overhead, harder to integrate with async code
- Rejected in favor of native async support
## Date
2026-05-17
## Participants
- Development Team
+6 -6
View File
@@ -12,7 +12,7 @@ Git repositories are managed within projects. You can create bare repositories f
2. Click the **"New Repository"** button
3. Fill in the form:
- **Name**: Repository name (required)
- **Remote URL**: For cloning (optional)
- **Owner** and **Repository**: For SSH cloning from `git.commumedia.org`
- **Mirror Clone**: Toggle for mirror clones
4. Click **"Create Repository"**
@@ -25,12 +25,12 @@ Creates a new bare git repository. Use this for:
#### Clone from Remote
Enter a git URL to clone from:
- `https://github.com/user/repo.git`
- `git@github.com:user/repo.git`
- `https://gitlab.com/user/repo.git`
Enter the repository owner and name to clone from `git.commumedia.org` over SSH:
- `owner`: `alice`
- `repository`: `demo`
- Resulting SSH URL: `git@git.commumedia.org:alice/demo.git`
**Smart URL Parsing:** If you paste a browser URL (like `https://github.com/user/repo/tree/main`), the system will automatically suggest the correct git URL.
**Advanced fallback:** If needed, you can still paste a full git URL and the system will suggest the correct clone URL.
#### Mirror Clone

Some files were not shown because too many files have changed in this diff Show More