diff --git a/.github/scripts/bump-version-release.sh b/.github/scripts/bump-version-release.sh
index 8059731..387372d 100644
--- a/.github/scripts/bump-version-release.sh
+++ b/.github/scripts/bump-version-release.sh
@@ -4,6 +4,8 @@ set -e
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
VERSION_FILE="${REPO_ROOT}/VERSION"
BRANCH="${GITHUB_REF_NAME:-main}"
+SOURCE_BRANCH="next"
+
echo "On branch: $BRANCH"
cd "$REPO_ROOT"
@@ -35,3 +37,12 @@ fi
# push commit and tags
git push origin "$BRANCH"
git push origin --tags
+
+# sync new version back into 'next' branch
+echo "Syncing version $new_version back into $SOURCE_BRANCH..."
+git checkout "$SOURCE_BRANCH"
+git reset --hard "origin/$SOURCE_BRANCH"
+git merge "$TARGET_BRANCH" -m "Chore: sync version $new_version back into $SOURCE_BRANCH"
+git push origin "$SOURCE_BRANCH"
+
+echo "Version successfully bumped to $new_version and synced to $SOURCE_BRANCH!"
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 89ca2a3..46e9faf 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,14 +4,14 @@ repos:
rev: 25.1.0
hooks:
- id: black
- language_version: python3.13
+ language_version: python3.14
args: ["--line-length=160"]
- repo: https://github.com/PyCQA/isort
rev: 6.0.1
hooks:
- id: isort
- language_version: python3.13
+ language_version: python3.14
args: [ "--line-length", "160", "--profile", "black" ]
- repo: https://github.com/PyCQA/flake8
@@ -29,7 +29,6 @@ repos:
args:
- --config
- frontend/eslint.config.cjs
- additional_dependencies: [eslint@9.17.0]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.0.0
diff --git a/backend/rest_api/aide/__init__.py b/backend/rest_api/aide/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/rest_api/aide/apps.py b/backend/rest_api/aide/apps.py
new file mode 100644
index 0000000..b0d450e
--- /dev/null
+++ b/backend/rest_api/aide/apps.py
@@ -0,0 +1,10 @@
+"""Apps.py for the OAuth application."""
+
+from django.apps import AppConfig
+
+
+class AideConfig(AppConfig):
+ """Django AppConfig for the Aide application."""
+
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "rest_api.aide"
diff --git a/backend/rest_api/aide/consumers.py b/backend/rest_api/aide/consumers.py
new file mode 100644
index 0000000..cb40531
--- /dev/null
+++ b/backend/rest_api/aide/consumers.py
@@ -0,0 +1,27 @@
+import asyncio
+import logging
+
+from rest_api.oauth.consumers import BaseAuthConsumer
+
+_logger = logging.getLogger("aide")
+
+
+class ChatSimulatorConsumer(BaseAuthConsumer):
+ """
+ WebSocket consumer for handling chat messages and streaming responses.
+ """
+
+ async def receive_json(self, content, **kwargs):
+ action = content.get("action")
+ if action == "send_message":
+ # send initial status
+ await self.send_json({"type": "status", "content": "Processing your message..."})
+ await asyncio.sleep(1)
+
+ # stream tokens back to frontend
+ tokens = ["Hello! ", "I ", "am ", "streaming ", "via ", "WebSockets!"]
+ for token in tokens:
+ await self.send_json({"type": "token", "content": token})
+ await asyncio.sleep(0.5)
+
+ await self.send_json({"type": "done"})
diff --git a/backend/rest_api/aide/routing.py b/backend/rest_api/aide/routing.py
new file mode 100644
index 0000000..fe6c82f
--- /dev/null
+++ b/backend/rest_api/aide/routing.py
@@ -0,0 +1,6 @@
+from django.urls import re_path
+from rest_api.aide.consumers import ChatSimulatorConsumer
+
+websocket_urlpatterns = [
+ re_path(r"^ws/aide/chat/$", ChatSimulatorConsumer.as_asgi()),
+]
diff --git a/backend/rest_api/aide/tests.py b/backend/rest_api/aide/tests.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/rest_api/aide/urls.py b/backend/rest_api/aide/urls.py
new file mode 100644
index 0000000..637600f
--- /dev/null
+++ b/backend/rest_api/aide/urls.py
@@ -0,0 +1 @@
+urlpatterns = []
diff --git a/backend/rest_api/aide/views.py b/backend/rest_api/aide/views.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/rest_api/asgi.py b/backend/rest_api/asgi.py
index 3638cc7..8313417 100644
--- a/backend/rest_api/asgi.py
+++ b/backend/rest_api/asgi.py
@@ -9,8 +9,20 @@
import os
+from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
+from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.rest_api.settings")
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rest_api.settings")
-application = get_asgi_application()
+django_asgi_app = get_asgi_application()
+
+import rest_api.routing as ws_routing # noqa: E402 - must import after setting DJANGO_SETTINGS_MODULE env var
+
+application = ProtocolTypeRouter(
+ {
+ "http": django_asgi_app,
+ "websocket": AllowedHostsOriginValidator(AuthMiddlewareStack(URLRouter(ws_routing.websocket_urlpatterns))),
+ }
+)
diff --git a/backend/rest_api/oauth/constants.py b/backend/rest_api/oauth/constants.py
new file mode 100644
index 0000000..8ab3c5a
--- /dev/null
+++ b/backend/rest_api/oauth/constants.py
@@ -0,0 +1 @@
+TOKEN_NAME = "pandauitoken"
diff --git a/backend/rest_api/oauth/consumers.py b/backend/rest_api/oauth/consumers.py
new file mode 100644
index 0000000..59da2f7
--- /dev/null
+++ b/backend/rest_api/oauth/consumers.py
@@ -0,0 +1,48 @@
+import logging
+
+from channels.db import database_sync_to_async
+from channels.generic.websocket import AsyncJsonWebsocketConsumer
+from django.contrib.auth.models import AnonymousUser
+from rest_api.oauth.constants import TOKEN_NAME
+from rest_framework.authtoken.models import Token
+
+_logger = logging.getLogger("oauth")
+
+
+class BaseAuthConsumer(AsyncJsonWebsocketConsumer):
+ """
+ Base consumer handling cookie/session authentication and origin verification
+ for all WebSocket endpoints.
+ """
+
+ @database_sync_to_async
+ def get_user_from_cookie_token(self, token_key):
+ try:
+ return Token.objects.select_related("user").get(key=token_key).user
+ except (Token.DoesNotExist, Exception):
+ return AnonymousUser()
+
+ async def connect(self):
+ # get user from scope or cookie token
+ user = self.scope.get("user")
+ if not user or user.is_anonymous:
+ cookies = self.scope.get("cookies", {})
+ token_key = cookies.get(TOKEN_NAME)
+ if token_key:
+ user = await self.get_user_from_cookie_token(token_key)
+ self.scope["user"] = user
+
+ # reject unauthenticated connections
+ if not user or user.is_anonymous:
+ _logger.warning(f"Rejecting unauthorized WS connection to {self.scope['path']}")
+ await self.close(code=4001)
+ return
+
+ _logger.info(f"WS connected for user '{user.username}' at {self.scope['path']}")
+
+ # accept handshake
+ await self.accept()
+
+ async def disconnect(self, code):
+ user = self.scope.get("user", "Anonymous")
+ _logger.info(f"WS disconnected for user '{user}' (Code: {code}) at {self.scope['path']}")
diff --git a/backend/rest_api/oauth/permissions.py b/backend/rest_api/oauth/permissions.py
index 4f3dc0b..5d53f00 100644
--- a/backend/rest_api/oauth/permissions.py
+++ b/backend/rest_api/oauth/permissions.py
@@ -17,9 +17,16 @@
class GlobalPermission(BasePermission):
"""
- Custom permission to check if the authenticated user is a member of the specified experiment.
+ Attribute-Based and Role-Based Access Control (ABAC/RBAC) permission class for DRF views.
- Assumes that experiment membership is populated during OAuth token processing.
+ Authorization Logic:
+ 1. Unauthenticated requests are rejected immediately (`False`).
+ 2. Direct rule evaluation: Evaluates `(roles, obj_type, action, obj_dict, params)` against policy rules.
+ 3. Implied read access: For `read` requests, users with `write` or `delete` permissions on the resource
+ are automatically granted `read` access.
+ 4. Default-allow for read requests: If no explicit policy rule exists for the specified `(obj_type, 'read')`,
+ `read` access is allowed by default for authenticated users.
+ 5. Default-deny for write/delete requests: Modification actions require explicit policy rules.
"""
@property
@@ -34,8 +41,26 @@ def get_user_roles(self, user) -> list[str]:
"""Helper method to extract user roles from the request.user object."""
return list(user.groups.values_list("name", flat=True))
+ def has_explicit_read_policy(self, obj_type: str) -> bool:
+ """Returns True if any explicit policy rules exist for this object type on 'read'."""
+ if hasattr(self.authz, "enforcer"):
+ policies = self.authz.enforcer.get_filtered_policy(1, obj_type, "read")
+ return len(policies) > 0
+ return False
+
def has_permission(self, request, view):
- """Check if the user has permission based on their roles."""
+ """
+ Determines whether the incoming request user is authorized to perform the requested action on a view.
+
+ Args:
+ request (Request): The incoming Django REST Framework request object.
+ view (APIView): The DRF view handling the request. Expected attributes:
+ - `object_type` (str, optional): Defaults to "unknown".
+ - `action` (str, optional): DRF view action (e.g., 'list', 'retrieve', 'create').
+
+ Returns:
+ bool: True if the request is permitted, False otherwise.
+ """
if not request.user or not request.user.is_authenticated:
return False
@@ -58,10 +83,15 @@ def has_permission(self, request, view):
if self.authz.enforce(roles, obj_type, act, obj_dict, params):
return True
- # if 'read' failed, check if they have 'write' or 'delete', because those should also allow 'read' access
if act == "read":
+ # if 'read' failed, check if they have 'write' or 'delete', because those should also allow 'read' access
for higher_act in ["write", "delete"]:
if self.authz.enforce(roles, obj_type, higher_act, obj_dict, params):
+ _logger.info("Granting read access as user have write or/and delete one")
return True
+ # allow read action by default unless a rule explicitly set in policy for this object type
+ if not self.has_explicit_read_policy(obj_type):
+ _logger.info(f"Granting default read access as no explicit rules exist for {obj_type}")
+ return True
return False
diff --git a/backend/rest_api/oauth/policies/policy_atlas.csv b/backend/rest_api/oauth/policies/policy_atlas.csv
index e31a090..a41be89 100644
--- a/backend/rest_api/oauth/policies/policy_atlas.csv
+++ b/backend/rest_api/oauth/policies/policy_atlas.csv
@@ -1,5 +1,3 @@
p,atlas-adc-pandamon,error_description,read,{},{},allow
p,atlas-adc-pandamon,error_description,write,{},{},allow
-p,atlas-adc-pandamon,error_description,delete,{},{},allow
-p,atlas,job,read,{},{},allow
-p,atlas,task,read,{},{},allow
\ No newline at end of file
+p,atlas-adc-pandamon,error_description,delete,{},{},allow
\ No newline at end of file
diff --git a/backend/rest_api/oauth/utils.py b/backend/rest_api/oauth/utils.py
index 9c9feb2..dce1ad2 100644
--- a/backend/rest_api/oauth/utils.py
+++ b/backend/rest_api/oauth/utils.py
@@ -25,6 +25,7 @@
from django.contrib.auth.models import Group, User
from django.http import HttpRequest, HttpResponse
+from rest_api.oauth.constants import TOKEN_NAME
_logger = logging.getLogger("oauth")
@@ -51,9 +52,9 @@ def preserve_cookies(request: HttpRequest, response: HttpResponse) -> HttpRespon
)
# Set the token in cookies
- if request.user.is_authenticated and "pandauitoken" not in response.cookies:
+ if request.user.is_authenticated and TOKEN_NAME not in response.cookies:
response.set_cookie(
- "pandauitoken",
+ TOKEN_NAME,
request.user.auth_token.key,
httponly=True,
secure=False,
diff --git a/backend/rest_api/routing.py b/backend/rest_api/routing.py
new file mode 100644
index 0000000..269fb42
--- /dev/null
+++ b/backend/rest_api/routing.py
@@ -0,0 +1,6 @@
+from rest_api.aide.routing import websocket_urlpatterns as aide_ws
+
+# Combine all app websocket routes into a single list
+websocket_urlpatterns = [
+ *aide_ws,
+]
diff --git a/backend/rest_api/settings/base.py b/backend/rest_api/settings/base.py
index 5c7fb77..3a88784 100644
--- a/backend/rest_api/settings/base.py
+++ b/backend/rest_api/settings/base.py
@@ -6,6 +6,8 @@
import os
from pathlib import Path
+from django.utils.csp import CSP
+
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -23,6 +25,9 @@
# Application definition
INSTALLED_APPS = [
+ # websockets
+ "daphne",
+ "channels",
# django essentials
"django.contrib.auth",
"django.contrib.contenttypes",
@@ -39,10 +44,12 @@
"rest_api.oauth",
"rest_api.search",
"rest_api.task",
+ "rest_api.aide",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
+ "django.middleware.csp.ContentSecurityPolicyMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
@@ -52,6 +59,12 @@
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
+CHANNEL_LAYERS = {
+ "default": {
+ "BACKEND": "channels.layers.InMemoryChannelLayer",
+ },
+}
+
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
@@ -69,6 +82,7 @@
ROOT_URLCONF = "rest_api.urls"
+ASGI_APPLICATION = "rest_api.asgi.application"
WSGI_APPLICATION = "rest_api.wsgi.application"
# internationalization
@@ -77,8 +91,6 @@
USE_I18N = True
USE_TZ = False
-# static files (CSS, JavaScript, Images)
-STATIC_URL = "static/"
# UI frontend and backend URLs
FRONTEND_BASE_URL = os.getenv("PANDAUI_FRONTEND_BASE_URL", None)
@@ -89,3 +101,13 @@
PANDA_SERVER_API_URL = os.getenv("PANDA_SERVER_API_URL", None)
if not PANDA_SERVER_API_URL:
raise ValueError("PANDA_SERVER_API_URL environment variable is not set")
+
+
+SECURE_CSP = {
+ "default-src": [CSP.SELF],
+ "connect-src": [
+ CSP.SELF,
+ "https:",
+ "wss:",
+ ],
+}
diff --git a/backend/rest_api/settings/development.py b/backend/rest_api/settings/development.py
index c8feb93..e9d5b03 100644
--- a/backend/rest_api/settings/development.py
+++ b/backend/rest_api/settings/development.py
@@ -2,7 +2,7 @@
Development settings for the Django project.
"""
-from .base import FRONTEND_BASE_URL, REST_FRAMEWORK
+from .base import FRONTEND_BASE_URL, REST_FRAMEWORK, SECURE_CSP
from .logging import LOGGING
DEBUG = True
@@ -24,6 +24,11 @@
"x-csrftoken",
]
+SECURE_CSP_DEBUG = True
+SECURE_CSP["connect-src"].append(
+ f"wss://{FRONTEND_BASE_URL.replace('https://', '').replace('http://', '')}",
+)
+
# Make auth work with HTTP in development
SOCIAL_AUTH_REDIRECT_IS_HTTPS = False
# Make session work with HTTP in development
diff --git a/backend/rest_api/settings/logging.py b/backend/rest_api/settings/logging.py
index f7918c0..1b243a6 100644
--- a/backend/rest_api/settings/logging.py
+++ b/backend/rest_api/settings/logging.py
@@ -7,7 +7,8 @@
from .base import INSTALLED_APPS
LOG_LEVEL = os.getenv("PANDAUI_LOG_LEVEL", "INFO")
-LOG_PATH = os.getenv("PANDAUI_LOG_PATH", "/tmp") + "/"
+LOG_PATH = os.getenv("PANDAUI_LOG_PATH", "/tmp")
+LOG_PATH = LOG_PATH if LOG_PATH.endswith("/") else LOG_PATH + "/"
LOG_MAX_BYTES = int(os.getenv("PANDAUI_LOG_MAX_BYTES", 100 * 1024 * 1024))
# base logging configuration
@@ -49,7 +50,7 @@
"general_error": {
"level": "WARNING",
"class": "logging.FileHandler",
- "filename": f"{LOG_PATH}general_error.log",
+ "filename": f"{LOG_PATH}error.log",
"formatter": "verbose",
},
},
diff --git a/docker/backend/Dockerfile.backend b/docker/backend/Dockerfile.backend
index f2b0540..e2f1145 100644
--- a/docker/backend/Dockerfile.backend
+++ b/docker/backend/Dockerfile.backend
@@ -1,4 +1,4 @@
-ARG PYTHON_VERSION=3.13.0
+ARG PYTHON_VERSION=3.14.7
FROM docker.io/almalinux:9
ARG PYTHON_VERSION
@@ -16,7 +16,7 @@ RUN wget https://download.oracle.com/otn_software/linux/instantclient/2380000/or
yum install /tmp/oracle-instantclient-sqlplus-23.8.0.25.04-1.el9.x86_64.rpm -y && \
yum clean all && rm -rf /var/cache/yum
-# Python 3.13 installation
+# Python 3.14 installation
RUN mkdir /tmp/python && cd /tmp/python && \
wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz && \
tar -xzf Python-${PYTHON_VERSION}.tgz && \
@@ -27,8 +27,8 @@ RUN mkdir /tmp/python && cd /tmp/python && \
cd / && rm -rf /tmp/python
# Python toolchain symlinks
-RUN ln -s /usr/local/bin/python3.13 /usr/bin/python && \
- ln -s /usr/local/bin/pip3.13 /usr/bin/pip
+RUN ln -s /usr/local/bin/python3.14 /usr/bin/python && \
+ ln -s /usr/local/bin/pip3.14 /usr/bin/pip
# ENV and directory structure
ENV PYTHONPATH=/opt/pandaui/backend
diff --git a/docker/backend/nginx/nginx.conf b/docker/backend/nginx/nginx.conf
index dc9102c..7d98a10 100644
--- a/docker/backend/nginx/nginx.conf
+++ b/docker/backend/nginx/nginx.conf
@@ -53,8 +53,11 @@ http {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
+ proxy_read_timeout 86400s;
+ proxy_send_timeout 86400s;
}
}
}
diff --git a/docker/backend/requirements.txt b/docker/backend/requirements.txt
index 94a2ea5..26f8541 100644
--- a/docker/backend/requirements.txt
+++ b/docker/backend/requirements.txt
@@ -1,9 +1,13 @@
-daphne>=4.0,<5.0
-Django==6.0.5
-django-cors-headers==4.7.0
+channels==4.2.0
+daphne==4.2.1
+Django==6.0.7
+django-cors-headers==4.9.0
djangorestframework==3.16.0
oracledb==4.0.0
panda-authz==2.0.0
-python-dotenv==1.1.0
+python-dotenv==1.2.2
social-auth-app-django==5.4.3
-social-auth-core==4.6.1
\ No newline at end of file
+social-auth-core==4.6.1
+twisted[http2,tls]==25.5.0
+cryptography==45.0.7
+pyOpenSSL==25.1.0
\ No newline at end of file
diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html
index a0a3fe3..98ce266 100644
--- a/frontend/src/app/app.component.html
+++ b/frontend/src/app/app.component.html
@@ -1,7 +1,16 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss
index 8c7912a..14baabc 100644
--- a/frontend/src/app/app.component.scss
+++ b/frontend/src/app/app.component.scss
@@ -1,10 +1,10 @@
-:host .app-layout {
- display: flex;
- flex-direction: column;
- min-height: 100vh;
-
+:host {
+ .app-layout {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ }
.app-content {
- flex: 1 0 auto;
- padding: 0 0.5rem;
+ padding: 0.5rem;
}
}
diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts
index 445db0f..bc6e33a 100644
--- a/frontend/src/app/app.component.ts
+++ b/frontend/src/app/app.component.ts
@@ -1,18 +1,22 @@
-import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
+import { Component, inject, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HeaderComponent } from './core/layout/header/header.component';
import { FooterComponent } from './core/layout/footer/footer.component';
import { MessageBufferService } from './core/services/message-buffer.service';
+import { SidePanelComponent } from './core/layout/side-panel/side-panel.component';
+import { SidePanelService } from './core/layout/side-panel/side-panel.service';
+import { MatSidenavModule } from '@angular/material/sidenav';
@Component({
selector: 'app-root',
standalone: true,
- imports: [RouterOutlet, HeaderComponent, FooterComponent],
+ imports: [MatSidenavModule, RouterOutlet, HeaderComponent, FooterComponent, SidePanelComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent implements OnInit {
private messageBuffer = inject(MessageBufferService);
+ panelService = inject(SidePanelService);
title = 'PanDA UI';
ngOnInit(): void {
diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts
index 6fdffcb..81f099f 100644
--- a/frontend/src/app/app.config.ts
+++ b/frontend/src/app/app.config.ts
@@ -1,9 +1,9 @@
import { ApplicationConfig, ErrorHandler, provideAppInitializer, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, TitleStrategy } from '@angular/router';
-import { HTTP_INTERCEPTORS, provideHttpClient, withFetch, withInterceptorsFromDi } from '@angular/common/http';
+import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
-import { HttpErrorInterceptor } from './core/interceptors/http-error.interceptor';
-import { TokenInterceptor } from './core/interceptors/token.interceptor';
+import { httpErrorInterceptor } from './core/interceptors/http-error.interceptor';
+import { tokenInterceptor } from './core/interceptors/token.interceptor';
import { appInitializer } from './core/init/app.initializer';
import { AppTitleStrategy } from './core/services/app-title.service';
import { ErrorHandlerService } from './core/services/error-handler.service';
@@ -14,10 +14,7 @@ export const appConfig: ApplicationConfig = {
provideRouter(routes),
{ provide: ErrorHandler, useClass: ErrorHandlerService },
{ provide: TitleStrategy, useClass: AppTitleStrategy },
- // http clint and interceptors, interceptors must be provided before the http client
- { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true },
- { provide: HTTP_INTERCEPTORS, useClass: HttpErrorInterceptor, multi: true },
- provideHttpClient(withInterceptorsFromDi(), withFetch()),
+ provideHttpClient(withInterceptors([tokenInterceptor, httpErrorInterceptor])),
// init app configuration and authentication on app startup, must be last
provideAppInitializer(appInitializer),
],
diff --git a/frontend/src/app/core/interceptors/http-error.interceptor.ts b/frontend/src/app/core/interceptors/http-error.interceptor.ts
index 1a600fd..09e240a 100644
--- a/frontend/src/app/core/interceptors/http-error.interceptor.ts
+++ b/frontend/src/app/core/interceptors/http-error.interceptor.ts
@@ -1,57 +1,59 @@
-import { inject, Injectable } from '@angular/core';
-import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
+import { inject } from '@angular/core';
+import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { catchError } from 'rxjs/operators';
-import { Observable, throwError } from 'rxjs';
+import { EMPTY, Observable, throwError } from 'rxjs';
import { LoggingService } from '../services/logging.service';
import { MessageBufferService } from '../services/message-buffer.service';
-@Injectable()
-export class HttpErrorInterceptor implements HttpInterceptor {
- private log = inject(LoggingService).forContext('HttpErrorInterceptor');
- private messageBuffer = inject(MessageBufferService);
+export const httpErrorInterceptor: HttpInterceptorFn = (
+ req: HttpRequest,
+ next: HttpHandlerFn,
+): Observable> => {
+ const log = inject(LoggingService).forContext('HttpErrorInterceptor');
+ const messageBuffer = inject(MessageBufferService);
- intercept(req: HttpRequest, next: HttpHandler): Observable> {
- return next.handle(req).pipe(
- catchError((error: HttpErrorResponse) => {
- // Backend unreachable or offline
- if (error.status === 0) {
- this.messageBuffer.add('Connection Error: unable to reach the API server. Please try later :(', '', {
- duration: 0,
- panelClass: ['snackbar-error'],
- });
- this.log.error('Caught error: ', error);
- } else if (error.status === 500) {
- this.messageBuffer.add(
- 'Something went wrong on our end. Please try refreshing or try again in a few minutes. ',
- '',
- {
- duration: 0,
- panelClass: ['snackbar-warning'],
- },
- );
- this.log.error('Caught error: ', error);
- } else if (error.status === 501) {
- this.messageBuffer.add('Not implemented yet :(', 'Close', {
+ return next(req).pipe(
+ catchError((error: HttpErrorResponse) => {
+ // Backend unreachable or offline
+ if (error.status === 0) {
+ messageBuffer.add('Connection Error: unable to reach the API server. Please try later :(', '', {
+ duration: 0,
+ panelClass: ['snackbar-error'],
+ });
+ log.error('Caught error: ', error);
+ } else if (error.status === 500) {
+ messageBuffer.add(
+ 'Something went wrong on our end. Please try refreshing or try again in a few minutes. ',
+ 'Close',
+ {
duration: 0,
panelClass: ['snackbar-warning'],
- });
- this.log.warn('Caught error: Feature not implemented: ', req.url);
- } else if (error.status === 401) {
- this.log.warn('Unauthorized request:', req.url);
- }
- // Permission Errors (403)
- else if (error.status === 403) {
- this.messageBuffer.add('Forbidden: You do not have the necessary permissions for this action.', 'Close', {
- duration: 5000,
- panelClass: ['snackbar-error'],
- });
- this.log.warn('Access forbidden to:', req.url);
- } else {
- this.log.error('Unknown error', req.url, error);
+ },
+ );
+ log.error('Caught error: ', error);
+ } else if (error.status === 501) {
+ messageBuffer.add('Not implemented yet :(', 'Close', {
+ duration: 0,
+ panelClass: ['snackbar-warning'],
+ });
+ log.warn('Caught error: Feature not implemented: ', req.url);
+ } else if (error.status === 401) {
+ log.warn('Unauthorized request:', req.url);
+ if (req.url.includes('/oauth/')) {
+ return throwError(() => error);
}
- const errorMessage = error.message || 'An unexpected server error occurred.';
- return throwError(() => new Error(errorMessage));
- }),
- );
- }
-}
+ } else if (error.status === 403) {
+ messageBuffer.add('Forbidden: You do not have the necessary permissions for this action.', 'Close', {
+ duration: 5000,
+ panelClass: ['snackbar-error'],
+ });
+ log.warn('Access forbidden to:', req.url);
+ } else {
+ log.error('Unknown error', req.url, error);
+ // propagate it further to general error handler
+ return throwError(() => new Error(error.message || 'An unexpected server error occurred.'));
+ }
+ return EMPTY;
+ }),
+ );
+};
diff --git a/frontend/src/app/core/interceptors/token.interceptor.ts b/frontend/src/app/core/interceptors/token.interceptor.ts
index d168067..a524a9e 100644
--- a/frontend/src/app/core/interceptors/token.interceptor.ts
+++ b/frontend/src/app/core/interceptors/token.interceptor.ts
@@ -1,28 +1,28 @@
-import { inject, Injectable } from '@angular/core';
-import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
+import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
+import { inject } from '@angular/core';
import { Observable, switchMap, take } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { LoggingService } from '../services/logging.service';
-@Injectable()
-export class TokenInterceptor implements HttpInterceptor {
- private authService = inject(AuthService);
- private log = inject(LoggingService).forContext('TokenInterceptor');
+export const tokenInterceptor: HttpInterceptorFn = (
+ req: HttpRequest,
+ next: HttpHandlerFn,
+): Observable> => {
+ const authService = inject(AuthService);
+ const log = inject(LoggingService).forContext('TokenInterceptor');
- intercept(req: HttpRequest, next: HttpHandler): Observable> {
- // get the latest token value once for this request
- return this.authService.token$.pipe(
- take(1),
- switchMap((token) => {
- // clone the request and add headers if token exists
- const headers = token ? req.headers.set('Authorization', `Token ${token}`) : req.headers;
- const cloned = req.clone({
- headers,
- withCredentials: true,
- });
- this.log.debug('Token: ', token ? 'ok' : 'null');
- return next.handle(cloned);
- }),
- );
- }
-}
+ return authService.token$.pipe(
+ take(1),
+ switchMap((token) => {
+ // clone the request and add headers if token exists
+ const headers = token ? req.headers.set('Authorization', `Token ${token}`) : req.headers;
+ const cloned = req.clone({
+ headers,
+ withCredentials: true,
+ });
+
+ log.debug('Token: ', token ? 'ok' : 'null');
+ return next(cloned);
+ }),
+ );
+};
diff --git a/frontend/src/app/core/layout/header/header.component.html b/frontend/src/app/core/layout/header/header.component.html
index e0b3605..8f1d1e7 100644
--- a/frontend/src/app/core/layout/header/header.component.html
+++ b/frontend/src/app/core/layout/header/header.component.html
@@ -83,6 +83,16 @@
diff --git a/frontend/src/app/core/layout/header/header.component.scss b/frontend/src/app/core/layout/header/header.component.scss
index 87ef39d..cef0310 100644
--- a/frontend/src/app/core/layout/header/header.component.scss
+++ b/frontend/src/app/core/layout/header/header.component.scss
@@ -5,6 +5,8 @@
(
container-color: var(--mat-sys-inverse-surface),
item-label-text-color: var(--mat-sys-on-primary),
+ item-label-text-size: var(--mat-sys-label-medium-size),
+ item-label-text-weight: var(--mat-sys-label-medium-weight),
item-hover-state-layer-color: var(--mat-sys-on-surface),
item-focus-state-layer-color: (
--mat-sys-on-surface,
@@ -20,13 +22,16 @@
height: 2rem;
}
}
+
div.hamburger-wrapper {
align-items: center;
height: 100%;
}
+
a[routerLink='/'] {
color: var(--mat-sys-on-primary, #f8fafc);
- font-weight: bold;
+ font-size: var(--mat-sys-label-medium-size);
+ font-weight: var(--mat-sys-label-medium-weight);
text-decoration: none;
margin: auto 0.75rem;
}
@@ -39,6 +44,7 @@ a[routerLink='/'] {
border: none;
box-shadow: none;
padding: 0;
+ font-size: var(--mat-sys-headline-medium-size);
nav,
.flex {
@@ -53,6 +59,8 @@ a[routerLink='/'] {
border-radius: 0;
color: var(--mat-sys-on-primary);
padding: 0 0.5rem;
+ font-size: inherit;
+ font-weight: inherit;
&:hover,
&:focus,
&:active,
@@ -72,4 +80,24 @@ a[routerLink='/'] {
align-items: stretch;
height: 100%;
}
+
+ div.docs-wrapper {
+ display: flex;
+ align-items: center;
+ height: 100%;
+
+ button[mat-icon-button] {
+ color: var(--mat-sys-surface);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ mat-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+ }
+ }
+ }
}
diff --git a/frontend/src/app/core/layout/header/header.component.ts b/frontend/src/app/core/layout/header/header.component.ts
index cabee72..a15f43d 100644
--- a/frontend/src/app/core/layout/header/header.component.ts
+++ b/frontend/src/app/core/layout/header/header.component.ts
@@ -8,6 +8,8 @@ import { LoginComponent } from '../../../modules/auth/components/login/login.com
import { AppConfigService } from '../../services/app-config.service';
import { SearchOmniComponent } from '../../../modules/search/components/omni/omni.component';
import { MenuItem } from '../../models/menu-item';
+import { SidePanelComponent } from '../side-panel/side-panel.component';
+import { SidePanelService } from '../side-panel/side-panel.service';
@Component({
selector: 'app-header',
@@ -26,6 +28,7 @@ import { MenuItem } from '../../models/menu-item';
})
export class HeaderComponent implements OnInit {
private readonly config = inject(AppConfigService);
+ panelService = inject(SidePanelService);
items: MenuItem[] | undefined;
name = 'PanDA UI';
diff --git a/frontend/src/app/core/layout/side-panel/side-panel.component.html b/frontend/src/app/core/layout/side-panel/side-panel.component.html
new file mode 100644
index 0000000..864ebd4
--- /dev/null
+++ b/frontend/src/app/core/layout/side-panel/side-panel.component.html
@@ -0,0 +1,33 @@
+
+
+
+ Assistance Panel
+
+
+
+
+
+
+
+
+
+
+
+ @if (panelService.isAideEnabled) {
+
+
+
+ }
+
+
diff --git a/frontend/src/app/core/layout/side-panel/side-panel.component.scss b/frontend/src/app/core/layout/side-panel/side-panel.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/src/app/core/layout/side-panel/side-panel.component.spec.ts b/frontend/src/app/core/layout/side-panel/side-panel.component.spec.ts
new file mode 100644
index 0000000..5baf601
--- /dev/null
+++ b/frontend/src/app/core/layout/side-panel/side-panel.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { SidePanelComponent } from './side-panel.component';
+
+describe('SidePanelComponent', () => {
+ let component: SidePanelComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [SidePanelComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SidePanelComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/core/layout/side-panel/side-panel.component.ts b/frontend/src/app/core/layout/side-panel/side-panel.component.ts
new file mode 100644
index 0000000..c2df2c8
--- /dev/null
+++ b/frontend/src/app/core/layout/side-panel/side-panel.component.ts
@@ -0,0 +1,36 @@
+import { Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+import { MatCardModule } from '@angular/material/card';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatIconModule } from '@angular/material/icon';
+import { MatButtonModule } from '@angular/material/button';
+import { MatSidenavModule } from '@angular/material/sidenav';
+
+import { SidePanelService } from './side-panel.service';
+import { ChatComponent } from '../../../modules/aide/components/chat/chat.component';
+import { DocsComponent } from '../../../modules/aide/components/docs/docs.component';
+
+@Component({
+ selector: 'app-side-panel',
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatCardModule,
+ MatTabsModule,
+ MatIconModule,
+ MatButtonModule,
+ MatSidenavModule,
+ ChatComponent,
+ DocsComponent,
+ ],
+ templateUrl: './side-panel.component.html',
+ styleUrl: './side-panel.component.scss',
+})
+export class SidePanelComponent {
+ panelService = inject(SidePanelService);
+
+ onTabChange(index: number): void {
+ this.panelService.activeTab.set(index === 0 ? 'docs' : 'chat');
+ }
+}
diff --git a/frontend/src/app/core/layout/side-panel/side-panel.service.spec.ts b/frontend/src/app/core/layout/side-panel/side-panel.service.spec.ts
new file mode 100644
index 0000000..ada22d4
--- /dev/null
+++ b/frontend/src/app/core/layout/side-panel/side-panel.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { SidePanelService } from './side-panel.service';
+
+describe('SidePanelService', () => {
+ let service: SidePanelService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(SidePanelService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/core/layout/side-panel/side-panel.service.ts b/frontend/src/app/core/layout/side-panel/side-panel.service.ts
new file mode 100644
index 0000000..d4bf4f1
--- /dev/null
+++ b/frontend/src/app/core/layout/side-panel/side-panel.service.ts
@@ -0,0 +1,33 @@
+import { Injectable, signal, inject } from '@angular/core';
+import { AppConfigService } from '../../services/app-config.service';
+
+export type PanelTab = 'docs' | 'chat';
+
+@Injectable({ providedIn: 'root' })
+export class SidePanelService {
+ private configService = inject(AppConfigService);
+
+ readonly isOpen = signal(false);
+ readonly activeTab = signal('docs');
+
+ // Helper signal/getter to check if aide is enabled
+ get isAideEnabled(): boolean {
+ return this.configService.hasApp('aide');
+ }
+
+ togglePanel(tab?: PanelTab): void {
+ let targetTab = tab || this.activeTab();
+
+ // Prevent switching to chat if 'aide' isn't in runtime config
+ if (targetTab === 'chat' && !this.isAideEnabled) {
+ targetTab = 'docs';
+ }
+
+ if (tab && this.isOpen() && this.activeTab() === targetTab) {
+ this.isOpen.set(false);
+ } else {
+ this.activeTab.set(targetTab);
+ this.isOpen.set(true);
+ }
+ }
+}
diff --git a/frontend/src/app/core/models/app-config.model.ts b/frontend/src/app/core/models/app-config.model.ts
index c5e80b3..1ccf897 100644
--- a/frontend/src/app/core/models/app-config.model.ts
+++ b/frontend/src/app/core/models/app-config.model.ts
@@ -2,5 +2,6 @@ export type AppConfig = {
apiUrl: string;
logLevel: string;
production: boolean;
+ installedApps?: string[];
[key: string]: unknown; // allows future extension without TS errors
};
diff --git a/frontend/src/app/core/models/page-context.ts b/frontend/src/app/core/models/page-context.ts
new file mode 100644
index 0000000..e741847
--- /dev/null
+++ b/frontend/src/app/core/models/page-context.ts
@@ -0,0 +1,6 @@
+import { DocTopic } from '../../modules/aide/components/docs/docs.model';
+
+export interface PageContext {
+ pageTitle: string;
+ topics: DocTopic[];
+}
diff --git a/frontend/src/app/core/services/api.service.ts b/frontend/src/app/core/services/api.service.ts
index 90ef4f9..60ea226 100644
--- a/frontend/src/app/core/services/api.service.ts
+++ b/frontend/src/app/core/services/api.service.ts
@@ -45,6 +45,69 @@ export class ApiService {
return this.http.delete(`${this.apiBaseUrl}/${endpoint}/${id}/`);
}
+ stream(endpoint: string, body: unknown): Observable {
+ return new Observable((observer) => {
+ const controller = new AbortController();
+ const url = `${this.apiBaseUrl}/${endpoint.replace(/^\/|\/$/g, '')}/`;
+
+ fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ })
+ .then(async (response) => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+ if (!response.body) {
+ throw new Error('ReadableStream not supported or empty body');
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder('utf-8');
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n\n');
+ buffer = lines.pop() || ''; // Hold onto fragmented chunks
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const dataStr = line.replace('data: ', '').trim();
+ if (!dataStr) {
+ continue;
+ }
+
+ try {
+ const parsedEvent = JSON.parse(dataStr) as T;
+ observer.next(parsedEvent);
+ } catch (err) {
+ console.error('Error parsing SSE json chunk:', line);
+ }
+ }
+ }
+ }
+
+ observer.complete();
+ })
+ .catch((error) => {
+ if (error.name !== 'AbortError') {
+ observer.error(error);
+ }
+ });
+
+ // Cleanup logic: automatically cancels the fetch stream if the component unsubscribes!
+ return () => controller.abort();
+ });
+ }
+
makeParams(params: Record): HttpParams {
let httpParams = new HttpParams();
if (params) {
@@ -58,4 +121,12 @@ export class ApiService {
}
return httpParams;
}
+
+ generateUUID(): string {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = Math.floor(Math.random() * 16);
+ const v = c === 'x' ? r : (r % 4) + 8; // (r & 0x3) | 0x8 is mathematically equivalent to (r % 4) + 8
+ return v.toString(16);
+ });
+ }
}
diff --git a/frontend/src/app/core/services/app-config.service.ts b/frontend/src/app/core/services/app-config.service.ts
index 48e7cab..e0c1650 100644
--- a/frontend/src/app/core/services/app-config.service.ts
+++ b/frontend/src/app/core/services/app-config.service.ts
@@ -27,6 +27,24 @@ export class AppConfigService {
return this.config.apiUrl;
}
+ hasApp(appName: string): boolean {
+ const normalizedAppName = appName.toLowerCase();
+ const installedApps = this.config?.installedApps;
+ if (!installedApps) {
+ return false;
+ }
+ const apps = Array.isArray(installedApps)
+ ? installedApps
+ : typeof installedApps === 'string'
+ ? [installedApps]
+ : [];
+
+ return apps.some((app) => {
+ const normalized = app.toLowerCase();
+ return normalized === normalizedAppName || normalized === 'all';
+ });
+ }
+
// general getter for other config
get(key: K): AppConfig[K] {
return this.config[key];
diff --git a/frontend/src/app/core/services/error-handler.service.ts b/frontend/src/app/core/services/error-handler.service.ts
index 73438c4..492efa8 100644
--- a/frontend/src/app/core/services/error-handler.service.ts
+++ b/frontend/src/app/core/services/error-handler.service.ts
@@ -1,4 +1,4 @@
-import { ErrorHandler, Injectable, Injector, inject } from '@angular/core';
+import { ErrorHandler, inject, Injectable, Injector } from '@angular/core';
import { MessageBufferService } from './message-buffer.service';
import { LoggingService } from './logging.service';
@@ -46,9 +46,27 @@ export class ErrorHandlerService implements ErrorHandler {
}
private extractErrorMessage(error: unknown): string {
- if (error instanceof Error) {
- return error.message || error.stack || '';
+ if (!error) {
+ return '';
}
- return String(error ?? '');
+
+ const actualError =
+ (error as { reloadedError?: unknown; ngOriginalError?: unknown }).ngOriginalError ||
+ (error as { promise?: { reason?: unknown } }).promise?.reason ||
+ error;
+
+ if (actualError instanceof Error) {
+ return `${actualError.name}: ${actualError.message}\n${actualError.stack || ''}`;
+ }
+
+ if (typeof error === 'object') {
+ try {
+ return JSON.stringify(error);
+ } catch {
+ return String(error);
+ }
+ }
+
+ return String(error);
}
}
diff --git a/frontend/src/app/core/services/socket.service.spec.ts b/frontend/src/app/core/services/socket.service.spec.ts
new file mode 100644
index 0000000..7ceaf59
--- /dev/null
+++ b/frontend/src/app/core/services/socket.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { SocketService } from './socket.service';
+
+describe('SocketService', () => {
+ let service: SocketService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(SocketService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/core/services/socket.service.ts b/frontend/src/app/core/services/socket.service.ts
new file mode 100644
index 0000000..e451188
--- /dev/null
+++ b/frontend/src/app/core/services/socket.service.ts
@@ -0,0 +1,68 @@
+import { inject, Injectable } from '@angular/core';
+import { firstValueFrom, Observable } from 'rxjs';
+import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
+import { take } from 'rxjs/operators';
+import { AppConfigService } from './app-config.service';
+import { AuthService } from './auth.service';
+import { LoggingService } from './logging.service';
+
+export interface StreamEvent {
+ type: 'status' | 'tool_start' | 'tool_end' | 'token' | 'done' | 'error';
+ content?: string;
+ tool?: string;
+ result?: unknown;
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class SocketService {
+ private config = inject(AppConfigService);
+ private authService = inject(AuthService);
+ private log = inject(LoggingService).forContext('WebSocketService');
+
+ private socket$?: WebSocketSubject;
+
+ private get wsBaseUrl(): string {
+ const apiUrl = new URL(this.config.apiUrl);
+ const protocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${protocol}//${apiUrl.host}/`;
+ }
+
+ async connect(endpoint: string): Promise> {
+ if (this.socket$ && !this.socket$.closed) {
+ return this.socket$.asObservable() as Observable;
+ }
+
+ // use active auth token to connect to the websocket endpoint
+ const token = await firstValueFrom(this.authService.token$.pipe(take(1)));
+ const socketUrl = `${this.wsBaseUrl}ws/${endpoint}/`;
+
+ this.socket$ = webSocket({
+ url: socketUrl,
+ deserializer: (msg) => JSON.parse(msg.data),
+ serializer: (msg) => JSON.stringify(msg),
+ openObserver: {
+ next: () => this.log.debug('Connected to websocket at', socketUrl),
+ },
+ closeObserver: {
+ next: () => this.log.debug('Connection closed'),
+ },
+ });
+
+ return this.socket$.asObservable() as Observable;
+ }
+
+ sendMessage(payload: { action: string; message?: string; conversation_id?: string }): void {
+ if (!this.socket$ || this.socket$.closed) {
+ throw new Error('WebSocket is not connected');
+ }
+ this.socket$.next(payload);
+ }
+
+ disconnect(): void {
+ if (this.socket$) {
+ this.socket$.complete();
+ }
+ }
+}
diff --git a/frontend/src/app/modules/aide/components/chat/chat-stream.service.spec.ts b/frontend/src/app/modules/aide/components/chat/chat-stream.service.spec.ts
new file mode 100644
index 0000000..7c57496
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/chat/chat-stream.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { ChatStreamService } from './chat-stream.service';
+
+describe('ChatStreamService', () => {
+ let service: ChatStreamService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(ChatStreamService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/modules/aide/components/chat/chat-stream.service.ts b/frontend/src/app/modules/aide/components/chat/chat-stream.service.ts
new file mode 100644
index 0000000..24bba57
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/chat/chat-stream.service.ts
@@ -0,0 +1,145 @@
+import { inject, Injectable, signal } from '@angular/core';
+import { Subscription } from 'rxjs';
+import { SocketService, StreamEvent } from '../../../../core/services/socket.service';
+import { ApiService } from '../../../../core/services/api.service';
+import { LoggingService } from '../../../../core/services/logging.service';
+
+export interface ToolUsage {
+ name: string;
+ status: string;
+ result?: unknown;
+}
+
+export interface ChatMessage {
+ id: string;
+ sender: 'user' | 'assistant';
+ content: string;
+ statusText?: string;
+ toolsUsed: ToolUsage[];
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class ChatStreamService {
+ private chatSocket = inject(SocketService);
+ private api = inject(ApiService);
+ private log = inject(LoggingService).forContext('ChatStreamService');
+
+ messages = signal([]);
+ isGenerating = signal(false);
+
+ private wsSubscription?: Subscription;
+ private currentAssistantMessageId?: string;
+
+ async sendMessage(prompt: string): Promise {
+ if (this.isGenerating()) {
+ this.log.warn('Generation already in progress');
+ return;
+ }
+ const userMsgId = this.api.generateUUID();
+ const assistantMsgId = this.api.generateUUID();
+ this.currentAssistantMessageId = assistantMsgId;
+
+ // instantly push user message and blank assistant message
+ this.messages.update((prev) => [
+ ...prev,
+ { id: userMsgId, sender: 'user', content: prompt, toolsUsed: [] },
+ {
+ id: assistantMsgId,
+ sender: 'assistant',
+ content: '',
+ statusText: 'Connecting to WebSocket...',
+ toolsUsed: [],
+ },
+ ]);
+
+ this.isGenerating.set(true);
+
+ try {
+ // connect to websocket endpoint
+ const stream$ = await this.chatSocket.connect('aide/chat');
+
+ // listen to incoming frames over websocket
+ if (!this.wsSubscription) {
+ this.wsSubscription = stream$.subscribe({
+ next: (event: StreamEvent) => {
+ if (this.currentAssistantMessageId) {
+ this.handleStreamEvent(this.currentAssistantMessageId, event);
+ }
+
+ if (event.type === 'done') {
+ this.isGenerating.set(false);
+ }
+ },
+ error: (err) => {
+ this.log.error('[WebSocket Error]:', err);
+
+ if (this.currentAssistantMessageId) {
+ this.patchAssistantMessage(this.currentAssistantMessageId, (msg) => ({
+ ...msg,
+ statusText: undefined,
+ content: msg.content + '\n[Error: WebSocket connection lost]',
+ }));
+ }
+
+ this.isGenerating.set(false);
+ },
+ });
+ }
+
+ // dispatch the prompt frame over the open socket connection
+ this.chatSocket.sendMessage({
+ action: 'send_message',
+ message: prompt,
+ });
+ } catch (err) {
+ this.log.error('[WebSocket setup failed]:', err);
+ this.patchAssistantMessage(assistantMsgId, (msg) => ({
+ ...msg,
+ statusText: undefined,
+ content: '\n[Error: Could not establish WebSocket connection]',
+ }));
+ this.isGenerating.set(false);
+ }
+ }
+
+ stopGeneration(): void {
+ if (this.isGenerating()) {
+ // Send cancellation signal directly over the socket if desired
+ try {
+ this.chatSocket.sendMessage({ action: 'cancel' });
+ } catch (e) {
+ // Socket might already be closed
+ }
+ this.isGenerating.set(false);
+ }
+ }
+
+ private handleStreamEvent(messageId: string, event: StreamEvent): void {
+ this.patchAssistantMessage(messageId, (msg) => {
+ switch (event.type) {
+ case 'status':
+ this.log.debug(`Status update: ${event.content}`);
+ return { ...msg, statusText: event.content };
+
+ case 'token':
+ return {
+ ...msg,
+ statusText: undefined,
+ content: msg.content + (event.content || ''),
+ };
+
+ case 'done':
+ return { ...msg, statusText: undefined };
+
+ default:
+ return msg;
+ }
+ });
+ }
+
+ private patchAssistantMessage(id: string, updateFn: (msg: ChatMessage) => ChatMessage): void {
+ this.messages.update((prev) => prev.map((msg) => (msg.id === id ? updateFn(msg) : msg)));
+ }
+}
diff --git a/frontend/src/app/modules/aide/components/chat/chat.component.html b/frontend/src/app/modules/aide/components/chat/chat.component.html
new file mode 100644
index 0000000..cebdc82
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/chat/chat.component.html
@@ -0,0 +1,65 @@
+
+
+
+ @for (msg of chatService.messages(); track msg.id) {
+
+
+
+
+ {{ msg.sender | titlecase }}
+
+
+
+
+
+ @if (msg.toolsUsed.length > 0) {
+
+ @for (tool of msg.toolsUsed; track tool.name) {
+
+
+ {{ tool.name }} {{ tool.status === 'running' ? '...' : '✓' }}
+
+ }
+
+ }
+
+
+ @if (msg.statusText) {
+
+
+ {{ msg.statusText }}
+
+ }
+
+
+ {{ msg.content }}
+
+
+
+ }
+
+
+
+
+
+ Type a question...
+
+
+
+
+
+
diff --git a/frontend/src/app/modules/aide/components/chat/chat.component.scss b/frontend/src/app/modules/aide/components/chat/chat.component.scss
new file mode 100644
index 0000000..d9f4893
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/chat/chat.component.scss
@@ -0,0 +1,147 @@
+:host {
+ display: block;
+ height: 100%;
+ width: 100%;
+
+ .chat-wrapper {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ background-color: var(--mat-sys-surface-container-lowest, #f9fafb);
+ overflow: hidden;
+ }
+
+ .message-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: var(--mat-sys-spacing, 0.3rem);
+ }
+
+ .bubble-container {
+ display: flex;
+ width: 100%;
+ justify-content: flex-start;
+
+ .message-card {
+ max-width: 80%;
+ border-radius: 0.5rem;
+ background-color: var(--mat-sys-surface-container);
+ box-shadow: none;
+
+ /* Compact Angular Material Card Headers */
+ mat-card-header {
+ padding: 0.5rem 0.5rem 0 0.5rem;
+ min-height: auto;
+ background-color: inherit;
+ border-radius: inherit;
+ }
+
+ .sender-meta {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--mat-sys-on-surface-variant);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+
+ mat-card-content {
+ padding: 0 0.5rem 0.5rem 0.5rem;
+ }
+
+ .content-text {
+ margin: 0;
+ font-size: 0.875rem;
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+ }
+
+ /* User Message Variant (Right aligned, Primary background) */
+ &.user {
+ justify-content: flex-end;
+
+ .message-card {
+ background-color: var(--mat-sys-primary-container, #e0e7ff);
+ color: var(--mat-sys-on-primary-container, #1e1b4b);
+
+ .sender-meta {
+ color: var(--mat-sys-on-primary-container, #3730a3);
+ opacity: 0.8;
+ }
+ }
+ }
+ }
+
+ .tools-container {
+ margin-bottom: 0.5rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+
+ mat-chip {
+ --mdc-chip-container-height: 22px;
+ font-size: 0.7rem;
+
+ mat-icon {
+ font-size: 0.875rem;
+ width: 0.875rem;
+ height: 0.875rem;
+ }
+
+ &.completed {
+ background-color: var(--mat-sys-secondary-container, #d1fae5);
+ color: var(--mat-sys-on-secondary-container, #065f46);
+ }
+ }
+ }
+
+ .status-indicator {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ font-size: 0.75rem;
+ color: var(--mat-sys-on-surface-variant, #6b7280);
+ font-style: italic;
+
+ mat-spinner {
+ flex-shrink: 0;
+ }
+ }
+
+ .input-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ background-color: var(--mat-sys-surface-container-lowest, #ffffff);
+
+ .chat-input {
+ flex: 1;
+ /* Remove bottom space reserved for error messages */
+ ::ng-deep .mat-mdc-form-field-subscript-wrapper {
+ display: none;
+ }
+ /* Make input field compact to match low density themes */
+ ::ng-deep .mat-mdc-text-field-wrapper {
+ padding: 0 0.5rem;
+ }
+ textarea[matInput] {
+ resize: none;
+ }
+ }
+
+ button[matIconButton] {
+ color: var(--mat-sys-primary);
+
+ &:disabled {
+ color: var(--mat-sys-outline);
+ }
+ }
+ }
+}
diff --git a/frontend/src/app/modules/aide/components/chat/chat.component.spec.ts b/frontend/src/app/modules/aide/components/chat/chat.component.spec.ts
new file mode 100644
index 0000000..5b89000
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/chat/chat.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ChatComponent } from './chat.component';
+
+describe('ChatComponent', () => {
+ let component: ChatComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ChatComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ChatComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/modules/aide/components/chat/chat.component.ts b/frontend/src/app/modules/aide/components/chat/chat.component.ts
new file mode 100644
index 0000000..082492f
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/chat/chat.component.ts
@@ -0,0 +1,45 @@
+import { Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatCardModule } from '@angular/material/card';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatChipsModule } from '@angular/material/chips';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+
+import { ChatStreamService } from './chat-stream.service';
+import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field';
+
+@Component({
+ selector: 'app-chat',
+ imports: [
+ CommonModule,
+ FormsModule,
+ TextFieldModule,
+ CdkTextareaAutosize,
+ MatCardModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatButtonModule,
+ MatIconModule,
+ MatChipsModule,
+ MatProgressSpinnerModule,
+ ],
+ templateUrl: './chat.component.html',
+ styleUrl: './chat.component.scss',
+})
+export class ChatComponent {
+ chatService = inject(ChatStreamService);
+ userInput = '';
+
+ send(): void {
+ if (!this.userInput.trim() || this.chatService.isGenerating()) {
+ return;
+ }
+ const text = this.userInput;
+ this.userInput = '';
+ this.chatService.sendMessage(text);
+ }
+}
diff --git a/frontend/src/app/modules/aide/components/docs/docs.component.html b/frontend/src/app/modules/aide/components/docs/docs.component.html
new file mode 100644
index 0000000..4c086f2
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/docs/docs.component.html
@@ -0,0 +1,43 @@
+
+
+
+ @if (pageContext(); as ctx) {
+
+
+
+ {{ ctx.pageTitle }}
+
+ Contextual
+
+
+
+ @for (topic of ctx.topics; track topic.id) {
+
+
{{ topic.title }}
+
{{ topic.content }}
+
+ } @empty {
+
No page-specific topics available.
+ }
+
+
+ }
+
+
+
+
+ General Docs
+ Global Help
+
+
+
+ @for (topic of globalTopics(); track topic.id) {
+
+
{{ topic.title }}
+
{{ topic.content }}
+
+ }
+
+
+
+
diff --git a/frontend/src/app/modules/aide/components/docs/docs.component.scss b/frontend/src/app/modules/aide/components/docs/docs.component.scss
new file mode 100644
index 0000000..5e7bddf
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/docs/docs.component.scss
@@ -0,0 +1,22 @@
+:host {
+ .docs-topics-container {
+ .docs-topic {
+ //border: 1px solid var(--mat-sys-outline-variant);
+ border-radius: 0.5rem;
+ padding: 0.3rem;
+ margin-bottom: 0.5rem;
+ background-color: var(--mat-sys-surface-container-lowest);
+
+ .docs-topic-title {
+ font-size: var(--mat-sys-title-medium-size);
+ font-weight: var(--mat-sys-title-medium-weight);
+ padding: 0 0 0.5rem 0;
+ }
+ .docs-topic-content {
+ font-size: var(--mat-sys-body-medium-size);
+ line-height: var(--mat-sys-body-small-line-height);
+ color: var(--mat-sys-on-surface-variant);
+ }
+ }
+ }
+}
diff --git a/frontend/src/app/modules/aide/components/docs/docs.component.spec.ts b/frontend/src/app/modules/aide/components/docs/docs.component.spec.ts
new file mode 100644
index 0000000..8f64157
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/docs/docs.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DocsComponent } from './docs.component';
+
+describe('DocsComponent', () => {
+ let component: DocsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DocsComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DocsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/modules/aide/components/docs/docs.component.ts b/frontend/src/app/modules/aide/components/docs/docs.component.ts
new file mode 100644
index 0000000..f562e8b
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/docs/docs.component.ts
@@ -0,0 +1,48 @@
+import { Component, effect, inject, signal } from '@angular/core';
+import { SidePanelService } from '../../../../core/layout/side-panel/side-panel.service';
+import { DocsService } from './docs.service';
+import { MatExpansionModule } from '@angular/material/expansion';
+
+@Component({
+ selector: 'app-docs',
+ imports: [MatExpansionModule],
+ templateUrl: './docs.component.html',
+ styleUrl: './docs.component.scss',
+})
+export class DocsComponent {
+ private docsService = inject(DocsService);
+ protected panelService = inject(SidePanelService);
+
+ readonly pageContext = this.docsService.currentPageContext;
+ readonly globalTopics = this.docsService.globalTopics;
+
+ readonly isGlobalOpen = signal(true);
+
+ constructor() {
+ // smooth scroll when an anchor is activated
+ effect(() => {
+ const anchorId = this.docsService.activeAnchor();
+ const isOpen = this.panelService.isOpen();
+ const isDocsTab = this.panelService.activeTab() === 'docs';
+
+ if (isOpen && isDocsTab && anchorId) {
+ setTimeout(() => {
+ const el = document.getElementById(`help-topic-${anchorId}`);
+ if (el) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+
+ // Visual ring/flash indicator
+ el.classList.add('ring-2', 'ring-indigo-500', 'bg-indigo-50/50');
+ setTimeout(() => {
+ el.classList.remove('ring-2', 'ring-indigo-500', 'bg-indigo-50/50');
+ }, 2000);
+ }
+ }, 150);
+ }
+ });
+ }
+
+ toggleGlobalAccordion(): void {
+ this.isGlobalOpen.update((open) => !open);
+ }
+}
diff --git a/frontend/src/app/modules/aide/components/docs/docs.model.ts b/frontend/src/app/modules/aide/components/docs/docs.model.ts
new file mode 100644
index 0000000..8930832
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/docs/docs.model.ts
@@ -0,0 +1,5 @@
+export interface DocTopic {
+ id: string;
+ title: string;
+ content: string;
+}
diff --git a/frontend/src/app/modules/aide/components/docs/docs.service.spec.ts b/frontend/src/app/modules/aide/components/docs/docs.service.spec.ts
new file mode 100644
index 0000000..0ec387e
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/docs/docs.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { DocsService } from './docs.service';
+
+describe('DocsService', () => {
+ let service: DocsService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(DocsService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/modules/aide/components/docs/docs.service.ts b/frontend/src/app/modules/aide/components/docs/docs.service.ts
new file mode 100644
index 0000000..1ef08b4
--- /dev/null
+++ b/frontend/src/app/modules/aide/components/docs/docs.service.ts
@@ -0,0 +1,43 @@
+import { inject, Injectable, Service, signal } from '@angular/core';
+import { DocTopic } from './docs.model';
+import { LoggingService } from '../../../../core/services/logging.service';
+import { PageContext } from '../../../../core/models/page-context';
+
+const DOCS_GLOBAL: DocTopic[] = [
+ {
+ id: 'authentication',
+ title: 'Authentication & API Access',
+ content:
+ 'You can authenticate using Custom API Tokens from your Profile page. Pass tokens in the Authorization header.',
+ },
+];
+
+@Injectable({ providedIn: 'root' })
+export class DocsService {
+ private log = inject(LoggingService).forContext('DocsService');
+
+ readonly isOpen = signal(false);
+ readonly globalTopics = signal(DOCS_GLOBAL);
+ readonly activeAnchor = signal(null);
+ readonly currentPageContext = signal(null);
+
+ openDocs(anchorId?: string): void {
+ this.isOpen.set(true);
+ if (anchorId) {
+ this.activeAnchor.set(anchorId);
+ }
+ }
+
+ closeDocs(): void {
+ this.isOpen.set(false);
+ }
+
+ toggleDocs(): void {
+ this.isOpen.update((open) => !open);
+ }
+
+ setPageContext(context: PageContext | null): void {
+ this.log.debug('[DocsService] Docs content updated', context);
+ this.currentPageContext.set(context);
+ }
+}
diff --git a/frontend/src/app/modules/auth/components/login/login.component.scss b/frontend/src/app/modules/auth/components/login/login.component.scss
index fd63e8f..aea2486 100644
--- a/frontend/src/app/modules/auth/components/login/login.component.scss
+++ b/frontend/src/app/modules/auth/components/login/login.component.scss
@@ -12,7 +12,7 @@
border-radius: 50%;
background-color: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
- font-size: 0.875rem;
+ font-size: var(--mat-sys-label-medium-size);
font-weight: 500;
display: inline-flex;
@@ -37,7 +37,6 @@
&:focus-visible,
&[aria-expanded='true'] {
background-color: var(--mat-sys-tertiary-container);
- font-weight: 700;
text-shadow: 0 0 0.5px currentColor;
}
}
diff --git a/frontend/src/app/modules/job/components/job-error-description-form/job-error-description-form.component.spec.ts b/frontend/src/app/modules/job/components/job-error-description-form/job-error-description-form.component.spec.ts
index 3adaf6e..514a4bf 100644
--- a/frontend/src/app/modules/job/components/job-error-description-form/job-error-description-form.component.spec.ts
+++ b/frontend/src/app/modules/job/components/job-error-description-form/job-error-description-form.component.spec.ts
@@ -3,7 +3,7 @@ import { ComponentRef } from '@angular/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JobErrorDescriptionFormComponent } from './job-error-description-form.component';
-import { ErrorDescription } from '../../../../core/models/error-description.model';
+import { ErrorDescription } from '../../error-description.model';
import { OptionObject } from '../../../../core/models/option.model';
import { LoggingService } from '../../../../core/services/logging.service';
diff --git a/frontend/src/app/modules/job/components/job-error-description-form/job-error-description-form.component.ts b/frontend/src/app/modules/job/components/job-error-description-form/job-error-description-form.component.ts
index 4cc0a4a..9ba3e4e 100644
--- a/frontend/src/app/modules/job/components/job-error-description-form/job-error-description-form.component.ts
+++ b/frontend/src/app/modules/job/components/job-error-description-form/job-error-description-form.component.ts
@@ -12,7 +12,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
-import { ErrorDescription } from '../../../../core/models/error-description.model';
+import { ErrorDescription } from '../../error-description.model';
import { OptionObject } from '../../../../core/models/option.model';
import { LoggingService } from '../../../../core/services/logging.service';
diff --git a/frontend/src/app/modules/job/components/job-error-description-list/job-error-description-list.component.ts b/frontend/src/app/modules/job/components/job-error-description-list/job-error-description-list.component.ts
index d4f0038..2ee7b58 100644
--- a/frontend/src/app/modules/job/components/job-error-description-list/job-error-description-list.component.ts
+++ b/frontend/src/app/modules/job/components/job-error-description-list/job-error-description-list.component.ts
@@ -9,12 +9,12 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
-import { ErrorDescription } from '../../../../core/models/error-description.model';
+import { ErrorDescription } from '../../error-description.model';
import { ApiService } from '../../../../core/services/api.service';
import { OptionObject } from '../../../../core/models/option.model';
import { JobErrorDescriptionFormComponent } from '../job-error-description-form/job-error-description-form.component';
-import { JobErrorCategoriesService } from '../../../../core/services/job-error-categories.service';
-import { JobErrorCategory } from '../../../../core/models/job-error-category.model';
+import { JobErrorCategoriesService } from '../../job-error-categories.service';
+import { JobErrorCategory } from '../../job-error-category.model';
import { LoggingService } from '../../../../core/services/logging.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
diff --git a/frontend/src/app/core/models/error-description.model.ts b/frontend/src/app/modules/job/error-description.model.ts
similarity index 100%
rename from frontend/src/app/core/models/error-description.model.ts
rename to frontend/src/app/modules/job/error-description.model.ts
diff --git a/frontend/src/app/core/services/job-error-categories.service.spec.ts b/frontend/src/app/modules/job/job-error-categories.service.spec.ts
similarity index 96%
rename from frontend/src/app/core/services/job-error-categories.service.spec.ts
rename to frontend/src/app/modules/job/job-error-categories.service.spec.ts
index 2dcb227..b3b4cfe 100644
--- a/frontend/src/app/core/services/job-error-categories.service.spec.ts
+++ b/frontend/src/app/modules/job/job-error-categories.service.spec.ts
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { JobErrorCategoriesService } from './job-error-categories.service';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
-import { JobErrorCategory } from '../models/job-error-category.model';
+import { JobErrorCategory } from './job-error-category.model';
import { firstValueFrom } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
diff --git a/frontend/src/app/core/services/job-error-categories.service.ts b/frontend/src/app/modules/job/job-error-categories.service.ts
similarity index 86%
rename from frontend/src/app/core/services/job-error-categories.service.ts
rename to frontend/src/app/modules/job/job-error-categories.service.ts
index 4854bcf..6a0c1a5 100644
--- a/frontend/src/app/core/services/job-error-categories.service.ts
+++ b/frontend/src/app/modules/job/job-error-categories.service.ts
@@ -1,9 +1,9 @@
import { inject, Injectable } from '@angular/core';
-import { ApiService } from './api.service';
+import { ApiService } from '../../core/services/api.service';
import { Observable, of } from 'rxjs';
-import { JobErrorCategory } from '../models/job-error-category.model';
+import { JobErrorCategory } from './job-error-category.model';
import { tap } from 'rxjs/operators';
-import { LoggingService } from './logging.service';
+import { LoggingService } from '../../core/services/logging.service';
@Injectable({
providedIn: 'root',
diff --git a/frontend/src/app/core/models/job-error-category.model.ts b/frontend/src/app/modules/job/job-error-category.model.ts
similarity index 100%
rename from frontend/src/app/core/models/job-error-category.model.ts
rename to frontend/src/app/modules/job/job-error-category.model.ts
diff --git a/frontend/src/app/modules/task/components/task-overview/task-overview.component.html b/frontend/src/app/modules/task/components/task-overview/task-overview.component.html
index c2db257..5a87211 100644
--- a/frontend/src/app/modules/task/components/task-overview/task-overview.component.html
+++ b/frontend/src/app/modules/task/components/task-overview/task-overview.component.html
@@ -1,2 +1 @@
-Task overview
diff --git a/frontend/src/app/modules/task/components/task-overview/task-overview.component.ts b/frontend/src/app/modules/task/components/task-overview/task-overview.component.ts
index 20f9d76..e38d3b3 100644
--- a/frontend/src/app/modules/task/components/task-overview/task-overview.component.ts
+++ b/frontend/src/app/modules/task/components/task-overview/task-overview.component.ts
@@ -1,6 +1,29 @@
-import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { TaskParamsListComponent } from '../task-params-list/task-params-list.component';
+import { DocsService } from '../../../aide/components/docs/docs.service';
+import { PageContext } from '../../../../core/models/page-context';
+
+const DOCS_TASK: PageContext = {
+ pageTitle: 'Task Overview',
+ topics: [
+ {
+ id: 'task-definition',
+ title: 'Task Definition',
+ content:
+ 'A task is a unit of workload to accomplish an indivisible scientific objective. ' +
+ 'If an objective is done in multiple steps, each step is mapped to a task. A task takes input and produces output. ' +
+ 'The goal of the task is to process the input entirely. Generally, input and output are collections of data files, ' +
+ 'but there are also other formats, such as a group of sequence numbers, metadata, notification, void, etc. ' +
+ 'Each task has a unique identifier JediTaskID in the system.',
+ },
+ {
+ id: 'task-status',
+ title: 'Task Status',
+ content: 'TBF',
+ },
+ ],
+};
@Component({
selector: 'app-task-overview',
@@ -9,12 +32,23 @@ import { TaskParamsListComponent } from '../task-params-list/task-params-list.co
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './task-overview.component.scss',
})
-export class TaskOverviewComponent implements OnInit {
+export class TaskOverviewComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
+ private docsService = inject(DocsService);
jeditaskid!: number;
ngOnInit(): void {
this.jeditaskid = +this.route.snapshot.paramMap.get('jeditaskid')!;
+ this.docsService.setPageContext(DOCS_TASK);
+ }
+
+ ngOnDestroy(): void {
+ // Clear context when leaving the route
+ this.docsService.setPageContext(null);
+ }
+
+ openDocs(topic_id: string): void {
+ this.docsService.openDocs(topic_id);
}
}
diff --git a/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.html b/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.html
index c3162e9..717136d 100644
--- a/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.html
+++ b/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.html
@@ -1,18 +1,16 @@
@let task = taskInfo(); @if (task) {
-
+
- Task Parameters
+ Task parameters
-
+
- | Param |
- {{ row.key }} |
+ {{ row.key }} |
- Value |
@if (row.value) {
{{ row.value }}
@@ -20,13 +18,12 @@
|
-
} @else {
-
+
Loading task parameters...
}
diff --git a/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.scss b/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.scss
index f4f2696..e69de29 100644
--- a/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.scss
+++ b/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.scss
@@ -1,3 +0,0 @@
-:host ::ng-deep .p-datatable .p-datatable-tbody > tr > td {
- border-width: 0;
-}
diff --git a/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.ts b/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.ts
index 1487256..a5dfe92 100644
--- a/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.ts
+++ b/frontend/src/app/modules/task/components/task-params-list/task-params-list.component.ts
@@ -1,7 +1,7 @@
-import { Component, inject, input, InputSignal, Signal, ChangeDetectionStrategy } from '@angular/core';
+import { Component, inject, input, InputSignal, Signal } from '@angular/core';
import { of, switchMap } from 'rxjs';
-import { Task } from '../../../../core/models/task.model';
-import { TaskService } from '../../../../core/services/task.service';
+import { Task } from '../../task.model';
+import { TaskService } from '../../task.service';
import { CommonModule } from '@angular/common';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card';
@@ -11,7 +11,6 @@ import { MatTableModule } from '@angular/material/table';
selector: 'app-task-params-list',
imports: [CommonModule, MatCardModule, MatTableModule],
templateUrl: './task-params-list.component.html',
- changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './task-params-list.component.scss',
})
export class TaskParamsListComponent {
diff --git a/frontend/src/app/core/models/task.model.ts b/frontend/src/app/modules/task/task.model.ts
similarity index 100%
rename from frontend/src/app/core/models/task.model.ts
rename to frontend/src/app/modules/task/task.model.ts
diff --git a/frontend/src/app/core/services/task.service.spec.ts b/frontend/src/app/modules/task/task.service.spec.ts
similarity index 100%
rename from frontend/src/app/core/services/task.service.spec.ts
rename to frontend/src/app/modules/task/task.service.spec.ts
diff --git a/frontend/src/app/core/services/task.service.ts b/frontend/src/app/modules/task/task.service.ts
similarity index 75%
rename from frontend/src/app/core/services/task.service.ts
rename to frontend/src/app/modules/task/task.service.ts
index 97e7ff2..3044659 100644
--- a/frontend/src/app/core/services/task.service.ts
+++ b/frontend/src/app/modules/task/task.service.ts
@@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core';
-import { ApiService } from './api.service';
+import { ApiService } from '../../core/services/api.service';
import { Observable } from 'rxjs';
-import { Task } from '../models/task.model';
+import { Task } from './task.model';
@Injectable({
providedIn: 'root',
diff --git a/frontend/src/styles/material-theme.scss b/frontend/src/styles/material-theme.scss
index f813503..d710711 100644
--- a/frontend/src/styles/material-theme.scss
+++ b/frontend/src/styles/material-theme.scss
@@ -15,7 +15,7 @@ html {
tertiary: app-theme.$tertiary-palette,
),
typography: Roboto,
- density: -1,
+ density: -5,
)
);
}
@@ -36,13 +36,45 @@ body {
// Reset the user agent margin.
margin: 0;
height: 100%;
-
- p {
- margin: 0.25rem 0;
- }
}
:root {
+ // Override system-level typography variables to use smaller font sizes and line heights
+ --mat-sys-title-large-size: 1rem;
+ --mat-sys-title-large-line-height: 1.25rem;
+ --mat-sys-title-medium-size: 0.875rem;
+ --mat-sys-title-medium-line-height: 1.125rem;
+ --mat-sys-title-small-size: 0.875rem;
+ --mat-sys-title-small-line-height: 1.125rem;
+
+ --mat-sys-body-large-size: 1rem;
+ --mat-sys-body-large-line-height: 1.25rem;
+ --mat-sys-body-medium-size: 0.875rem;
+ --mat-sys-body-medium-line-height: 1.125rem;
+ --mat-sys-body-small-size: 0.75rem;
+ --mat-sys-body-small-line-height: 1rem;
+
+ --mat-sys-display-large-size: 1rem;
+ --mat-sys-display-large-line-height: 1.25rem;
+ --mat-sys-display-medium-size: 0.875rem;
+ --mat-sys-display-medium-line-height: 1.125rem;
+ --mat-sys-display-small-size: 0.75rem;
+ --mat-sys-display-small-line-height: 1rem;
+
+ --mat-sys-label-large-size: 1rem;
+ --mat-sys-label-large-line-height: 1.25rem;
+ --mat-sys-label-medium-size: 0.875rem;
+ --mat-sys-label-medium-line-height: 1.125rem;
+ --mat-sys-label-small-size: 0.75rem;
+ --mat-sys-label-small-line-height: 1rem;
+
+ --mat-sys-headline-large-size: 1rem;
+ --mat-sys-headline-large-line-height: 1.25rem;
+ --mat-sys-headline-medium-size: 0.875rem;
+ --mat-sys-headline-medium-line-height: 1.125rem;
+ --mat-sys-headline-small-size: 0.875rem;
+ --mat-sys-headline-small-line-height: 1.125rem;
+
@include mat.button-overrides(
(
text-container-shape: 0px,
@@ -62,8 +94,29 @@ body {
outlined-container-shape: 0,
outlined-container-color: var(--mat-sys-surface),
outlined-outline-color: var(--mat-sys-secondary),
- title-text-line-height: 1rem,
- title-text-size: 0.875rem,
+ title-text-line-height: var(--mat-sys-title-medium-line-height),
+ title-text-size: var(--mat-sys-title-medium-size),
+ title-text-weight: var(--mat-sys-title-medium-weight),
+ )
+ );
+
+ @include mat.expansion-overrides(
+ (
+ container-shape: 0,
+ header-expanded-state-height: 48px,
+ header-collapsed-state-height: 32px,
+ container-background-color: var(--mat-sys-secondary-container),
+ )
+ );
+
+ @include mat.fab-overrides(
+ (
+ container-shape: 0.5rem,
+ small-container-shape: 0.5rem,
+ extended-container-height: 2.5rem,
+ extended-container-shape: 0.5rem,
+ touch-target-size: 2rem,
+ small-touch-target-size: 1.5rem,
)
);
@@ -85,7 +138,7 @@ body {
@include mat.icon-button-overrides(
(
- icon-size: 1rem,
+ icon-size: 1.5rem,
state-layer-size: 1.5rem,
touch-target-size: 2rem,
focus-state-layer-opacity: 0,
@@ -113,6 +166,12 @@ body {
)
);
+ @include mat.sidenav-overrides(
+ (
+ container-shape: 0,
+ )
+ );
+
@include mat.snack-bar-overrides(
(
container-shape: 0,
@@ -124,17 +183,17 @@ body {
@include mat.table-overrides(
(
- header-container-height: 1rem,
- header-headline-line-height: 1rem,
- header-headline-size: 1rem,
+ header-container-height: var(--mat-sys-headline-small-line-height),
+ header-headline-line-height: var(--mat-sys-headline-small-line-height),
+ header-headline-size: var(--mat-sys-headline-small-size),
header-headline-weight: bold,
- row-item-container-height: 1rem,
- row-item-label-text-line-height: 1rem,
- row-item-label-text-size: 1rem,
+ row-item-container-height: var(--mat-sys-body-medium-line-height),
+ row-item-label-text-line-height: var(--mat-sys-body-medium-line-height),
+ row-item-label-text-size: var(--mat-sys-body-medium-size),
row-item-outline-width: 0px,
- footer-container-height: 1rem,
- footer-supporting-text-line-height: 1rem,
- footer-supporting-text-size: 1rem,
+ footer-container-height: var(--mat-sys-headline-small-line-height),
+ footer-supporting-text-line-height: var(--mat-sys-headline-small-line-height),
+ footer-supporting-text-size: var(--mat-sys-headline-small-size),
footer-supporting-text-weight: bold,
)
);
@@ -150,6 +209,29 @@ body {
);
// Overrides for non-tokenized features
+ mat-card {
+ .mat-mdc-card-header {
+ padding: 0.5rem;
+ background-color: var(--mat-sys-secondary-container, #ffffff);
+ }
+
+ .mat-mdc-card-content {
+ padding: 0 0.5rem;
+ }
+ }
+
+ mat-expansion-panel {
+ mat-expansion-panel-header {
+ padding: 0 0.5rem;
+
+ mat-panel-description {
+ font-size: var(--mat-sys-body-small-size);
+ }
+ }
+ .mat-expansion-panel-body {
+ padding: 0 0.5rem 0.5rem 0.5rem;
+ }
+ }
.mat-mdc-form-field {
.mat-mdc-text-field-wrapper {
@@ -180,6 +262,14 @@ body {
}
}
+ mat-sidenav {
+ width: 100vw;
+
+ @media (min-width: 640px) {
+ width: 420px;
+ }
+ }
+
.mat-mdc-table {
th.mat-mdc-header-cell {
padding: 0.2rem 0.5rem;