Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,11 @@ dmypy.json
!**/.gitkeep

# Rally was extracted into its own standalone repo (Platform-rally-extension)
# and is no longer a submodule. A local clone may still sit here; ignore it.
# and is no longer a submodule. A local clone (or a leftover worktree of the
# old submodule, e.g. extensions/rally-worktree-session-*/) may still sit
# here; ignore it.
extensions/rally/
extensions/rally-worktree-session-*/

# Extension build artifacts (submodules handle their own .gitignore)
extensions/*/web-*/node_modules/
Expand Down
56 changes: 15 additions & 41 deletions EXTENSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,8 @@ The NEI Platform supports extensions that can be enabled or disabled without mod
# Start with no extensions
./start-platform.sh

# Start with Rally extension
ENABLED_EXTENSIONS="rally" ./start-platform.sh

# Start with multiple extensions
ENABLED_EXTENSIONS="rally,gala" ./start-platform.sh
# Start with Gala extension
ENABLED_EXTENSIONS="gala" ./start-platform.sh
```

### Manual Extension Control
Expand All @@ -27,18 +24,16 @@ ENABLED_EXTENSIONS="rally,gala" ./start-platform.sh
# Disable all extensions
ENABLED_EXTENSIONS="" ./scripts/manage-extensions.sh

# Enable Rally only
ENABLED_EXTENSIONS="rally" ./scripts/manage-extensions.sh

# Enable multiple extensions
ENABLED_EXTENSIONS="rally,gala" ./scripts/manage-extensions.sh
# Enable Gala only
ENABLED_EXTENSIONS="gala" ./scripts/manage-extensions.sh
```

## Available Extensions

- **rally**: Rally Tascas extension (team management, checkpoints, scoring)
- **gala**: Gala extension (event management)

> Rally Tascas is no longer an embedded extension — it now runs as an independent, self-contained project (`Platform-rally-extension`) with its own database, auth, and reverse proxy. It is linked from the navbar as an external service, not managed through this system.

## Generic Extension Management System

The platform uses a generic extension management system that:
Expand Down Expand Up @@ -104,7 +99,7 @@ The generic extension management system can be adapted for external nginx server

```bash
# Generate extension nginx configs
ENABLED_EXTENSIONS="rally" ./scripts/manage-extensions.sh
ENABLED_EXTENSIONS="gala" ./scripts/manage-extensions.sh

# Copy configs to external nginx server
scp proxy/locations.*.conf user@nginx-server:/etc/nginx/conf.d/
Expand All @@ -128,11 +123,8 @@ The system works seamlessly with the existing GitHub Actions deploy workflow:
Create a `.env` file in the Platform root directory:

```bash
# Enable only Rally extension
ENABLED_EXTENSIONS=rally

# Enable multiple extensions
ENABLED_EXTENSIONS=rally,gala
# Enable only Gala extension
ENABLED_EXTENSIONS=gala

# Disable all extensions
ENABLED_EXTENSIONS=
Expand All @@ -150,15 +142,9 @@ docker-compose up -d --build api_nei
Override the environment variable directly in the command:

```bash
# Enable Rally
ENABLED_EXTENSIONS=rally docker-compose up -d --build api_nei

# Enable Gala
ENABLED_EXTENSIONS=gala docker-compose up -d --build api_nei

# Enable both
ENABLED_EXTENSIONS=rally,gala docker-compose up -d --build api_nei

# Disable all
ENABLED_EXTENSIONS= docker-compose up -d --build api_nei
```
Expand All @@ -169,21 +155,21 @@ You can directly modify the `compose.yml` file, but this is not recommended as i

```yaml
environment:
ENABLED_EXTENSIONS: "rally" # or "rally,gala" or ""
ENABLED_EXTENSIONS: "gala" # or ""
```

## Quick Start Examples

### Run with Rally Extension
### Run with Gala Extension

```bash
# Create .env file
echo "ENABLED_EXTENSIONS=rally" > .env
echo "ENABLED_EXTENSIONS=gala" > .env

# Start platform
docker-compose up -d --build api_nei

# Verify Rally is loaded
# Verify Gala is loaded
curl http://localhost:8000/api/nei/v1/extensions/manifest
```

Expand All @@ -200,16 +186,6 @@ docker-compose up -d --build api_nei
curl http://localhost:8000/api/nei/v1/extensions/manifest
```

### Run with Multiple Extensions

```bash
# Enable both Rally and Gala
echo "ENABLED_EXTENSIONS=rally,gala" > .env

# Start platform
docker-compose up -d --build api_nei
```

## Verification

### Check Extensions API
Expand All @@ -218,8 +194,8 @@ docker-compose up -d --build api_nei
# Check which extensions are loaded
curl http://localhost:8000/api/nei/v1/extensions/manifest

# Expected output with Rally enabled:
# {"nav":[{"label":"Rally Tascas","href":"/rally","requiresScopes":["manager-rally","admin"],"extension":"rally"}]}
# Expected output with Gala enabled:
# {"nav":[{"label":"Jantar Gala","href":"/gala","requiresScopes":["manager-gala","admin"],"extension":"gala"}]}

# Expected output with no extensions:
# {"nav":[]}
Expand All @@ -231,14 +207,12 @@ curl http://localhost:8000/api/nei/v1/extensions/manifest
# Check available OAuth2 scopes
curl http://localhost:8000/api/nei/v1/auth/scopes

# Rally extension adds: manager-rally, rally-staff
# Gala extension adds: manager-gala
```

### Check Frontend Navigation

Visit http://localhost:3000 and check if extension navigation items appear:
- **Rally**: "Rally Tascas" navtab
- **Gala**: Gala-related navigation items

## Extension Development
Expand Down
4 changes: 2 additions & 2 deletions api-nei/app/core/extension_scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ def register_scope(
Register a scope from an extension

Args:
extension_name: Name of the extension (e.g., 'rally', 'gala')
scope: The scope string (e.g., 'manager-rally')
extension_name: Name of the extension (e.g., 'gala')
scope: The scope string (e.g., 'manager-gala')
description: Human-readable description of the scope
"""
scope_key = f"{extension_name}:{scope}"
Expand Down
7 changes: 7 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ services:
context: web-nei
ports:
- 3000:3000
environment:
# Rally and Gamification are standalone external services (not
# embedded extensions) - set their URLs to show them in the navbar.
ENABLE_RALLY: ${ENABLE_RALLY:-False}
RALLY_URL: ${RALLY_URL:-}
ENABLE_GAMIFICATION: ${ENABLE_GAMIFICATION:-False}
GAMIFICATION_URL: ${GAMIFICATION_URL:-}
volumes:
- nei_modules:/web_nei/node_modules
- ./web-nei:/web_nei
Expand Down
21 changes: 10 additions & 11 deletions dev/proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,25 @@ This internal proxy is **not exposed externally** in production environments.

- `nginx.conf` - Base nginx configuration
- `locations.conf` - Core NEI routes (API and web)
- `locations.rally.conf` - Auto-generated by manage-extensions.sh (when Rally enabled)
- `locations.gala.conf` - Auto-generated by manage-extensions.sh (when Gala enabled)
- `nei.web.ua.pt/` - Domain-specific configs (development/production variants)

## How It Works

1. Developer runs: `ENABLED_EXTENSIONS="rally" ./start-platform.sh`
1. Developer runs: `ENABLED_EXTENSIONS="gala" ./start-platform.sh`
2. `manage-extensions.sh` generates extension-specific location files
3. `compose.yml` mounts these files into the proxy container
4. Proxy routes requests to internal container names:
- `api_rally` → Rally API container
- `web_rally` → Rally web container
- `api_gala` → Gala API container
- `web_gala` → Gala web container
- `api_nei` → Core API container
- `web_nei` → Core web container

## Architecture

### Development (this proxy)
```
Browser → proxy:80 → {api_nei, web_nei, api_rally, web_rally}
Browser → proxy:80 → {api_nei, web_nei, api_gala, web_gala}
```

### Production (Infrastructure/nginx)
Expand All @@ -53,20 +52,20 @@ Extension location blocks are generated by `../scripts/manage-extensions.sh`:
- **Enabled**: Routes to extension containers
- **Disabled**: Returns 404 or proxies to main platform

Example enabled config (`locations.rally.conf`):
Example enabled config (`locations.gala.conf`):
```nginx
location ~ ^/(api|static)/rally(/.*)?$ {
proxy_pass http://api_rally:8003;
location ~ ^/(api|static)/gala(/.*)?$ {
proxy_pass http://api_gala:8004;
}

location ~ ^/rally(/.*)?$ {
proxy_pass http://web_rally:3003;
location ~ ^/gala(/.*)?$ {
proxy_pass http://web_gala:3002;
}
```

Example disabled config:
```nginx
location ~ ^/(api|static)/rally(/.*)?$ {
location ~ ^/(api|static)/gala(/.*)?$ {
return 404;
}
```
Expand Down
6 changes: 6 additions & 0 deletions web-nei/.env
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
VITE_ENABLE_GALA=${ENABLE_GALA}

# Rally and Gamification are standalone external services, not embedded
# platform extensions - set their public URLs to enable the navbar links.
VITE_ENABLE_RALLY=${ENABLE_RALLY}
VITE_RALLY_URL=${RALLY_URL}
VITE_ENABLE_GAMIFICATION=${ENABLE_GAMIFICATION}
VITE_GAMIFICATION_URL=${GAMIFICATION_URL}

VITE_ANIMATION_BASE=0.05
VITE_ANIMATION_INCREMENT=0.1
116 changes: 116 additions & 0 deletions web-nei/src/__tests__/hooks/useServiceHealth.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useServiceHealth } from '../../hooks/useServiceHealth'

describe('useServiceHealth', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})

it('returns "checking" when no targets provided', () => {
const { result } = renderHook(() => useServiceHealth([]))
expect(result.current).toBe('checking')
})

it('returns "checking" when targets is not an array', () => {
const { result } = renderHook(() => useServiceHealth(undefined))
expect(result.current).toBe('checking')
})

it('returns "up" when all targets are reachable', async () => {
global.fetch = vi.fn().mockResolvedValue({})

const { result } = renderHook(() =>
useServiceHealth(['https://a.test', 'https://b.test'], { intervalMs: 60000 }),
)

await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})

await waitFor(() => expect(result.current).toBe('up'))
})

it('returns "down" when all targets fail', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('network error'))

const { result } = renderHook(() =>
useServiceHealth(['https://a.test'], { intervalMs: 60000 }),
)

await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})

await waitFor(() => expect(result.current).toBe('down'))
})

it('returns "degraded" when some targets fail', async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('network error'))

const { result } = renderHook(() =>
useServiceHealth(['https://a.test', 'https://b.test'], { intervalMs: 60000 }),
)

await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})

await waitFor(() => expect(result.current).toBe('degraded'))
})

it('re-checks on interval', async () => {
global.fetch = vi.fn().mockResolvedValue({})

renderHook(() => useServiceHealth(['https://a.test'], { intervalMs: 1000 }))

await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(global.fetch).toHaveBeenCalledTimes(1)

await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(global.fetch).toHaveBeenCalledTimes(2)
})

it('stops checking and clears interval on unmount', async () => {
global.fetch = vi.fn().mockResolvedValue({})

const { unmount } = renderHook(() =>
useServiceHealth(['https://a.test'], { intervalMs: 1000 }),
)

await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
const callsBeforeUnmount = global.fetch.mock.calls.length

unmount()

await act(async () => {
await vi.advanceTimersByTimeAsync(5000)
})
expect(global.fetch.mock.calls.length).toBe(callsBeforeUnmount)
})

it('ignores falsy target entries', async () => {
global.fetch = vi.fn().mockResolvedValue({})

renderHook(() => useServiceHealth([null, undefined, 'https://a.test', false]))

await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(global.fetch).toHaveBeenCalledTimes(1)
})
})
Loading
Loading