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;