diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5bcf387 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Checklist** + +- [ ] I have searched for, and not found, an issue for this bug + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..7054484 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Checklist** + +- [ ] I have searched for, and not found, an issue for this feature. + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index c9918f2..1412e04 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -20,7 +20,7 @@ jobs: - name: "Set up Python 3" uses: actions/setup-python@v2 with: - python-version: '3.8' + python-version: '3.12' - name: "Install dependencies" run: | pip install --upgrade pip @@ -38,7 +38,7 @@ jobs: - name: "Set up Python 3" uses: actions/setup-python@v2 with: - python-version: '3.8' + python-version: '3.12' - name: "Install dependencies" run: "pip install flake8" - name: "Run flake8!" @@ -53,10 +53,10 @@ jobs: username: ${{ secrets.TEST_DOCKER_USERNAME }} password: ${{ secrets.TEST_DOCKER_PASSWORD }} - uses: actions/checkout@v2 - - name: "Set up Python 3.8" + - name: "Set up Python 3.12" uses: actions/setup-python@v2 - with: - python-version: "3.8" + with: + python-version: "3.12" - name: "Install dependencies" run: | pip install --upgrade pip @@ -79,7 +79,7 @@ jobs: needs: [black, pylint, flake8, pytest-manager] strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: ["3.10", "3.11", "3.12"] steps: - name: Login to Docker Hub uses: docker/login-action@v1 @@ -118,12 +118,13 @@ jobs: - name: "Set up Python 3" uses: actions/setup-python@v2 with: - python-version: '3.8' + python-version: '3.12' - name: "Install dependencies" run: | pip install --upgrade pip - pip install -r requirements_manager.txt + pip install -r requirements_manager.txt pip install -r requirements_sdk.txt pip install -r requirements_dev.txt + python -m ipykernel install --user --name python3 - name: "Running E2E tests with pytest" run: "python -m pytest --verbose tests/e2e_test/" \ No newline at end of file diff --git a/.github/workflows/pypi-upload.yml b/.github/workflows/pypi-upload.yml index ba0a726..a38096e 100644 --- a/.github/workflows/pypi-upload.yml +++ b/.github/workflows/pypi-upload.yml @@ -18,7 +18,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install setuptools wheel twine packaging - name: Build and publish to pypi.org env: TWINE_USERNAME: ${{ secrets.GLOBAL_PYPI_USERNAME }} @@ -26,4 +26,4 @@ jobs: DAEPLOY_RELEASE_VERSION: ${{ github.event.release.tag_name }} run: | python setup.py bdist_wheel - twine upload dist/* \ No newline at end of file + twine upload dist/* diff --git a/.pylintrc b/.pylintrc index 7a6b09e..01fd8d1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,18 +1,25 @@ [pylint] -disable = +disable = R0801, - C0330, - C0326, no-self-argument, no-name-in-module, too-few-public-methods, too-many-arguments, + too-many-positional-arguments, logging-fstring-interpolation, fixme, missing-module-docstring, missing-function-docstring, missing-class-docstring, raise-missing-from, - unsubscriptable-object # TODO: Only required in python 3.9 + unsubscriptable-object, + consider-using-with, + use-dict-literal, + missing-timeout, + unspecified-encoding, + useless-option-value, + invalid-name, + import-error -max-line-length = 88 \ No newline at end of file +max-line-length = 88 +ignored-modules = IPython \ No newline at end of file diff --git a/CHANGES.md b/CHANGES.md index 7e2967f..fc6753b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,36 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## 1.5.0 + +### New Features + +- Logs viewer: in-page search with match highlighting and a match count, one-click export of the full log to a file, and a responsive full-screen console (both service and manager logs). +- Dashboard polish: wider Services panel with a slim, self-explanatory Notifications rail; `LOGS`/`DOCS` rendered as clear buttons with keyboard focus rings; content biased to the top with a small capped gap; and denser service rows so more services fit without scrolling. + +### Bugfixes + +- Logs export and search now return the actual log instead of a validation error (the view no longer requests the invalid `tail=all`). +- Stabilized the flaky `test_read_timerange` SDK test (a concurrent database clean could trim rows mid-test). + +## 1.4.0 + +### New Features + +- Redesigned web UI: a modern dark login, dashboard, and a streaming logs view with a Follow / auto-scroll toggle (for both service and manager logs). Fully self-contained — no external CDNs or hot-linked assets. + +### Changed + +- Modernized the manager and SDK dependency stack and moved to Python 3.12. + +### Bugfixes + +- Docker Engine 29 compatibility: `daeploy ls`, the dashboard, and service inspection no longer return HTTP 500 on hosts using the newer Docker storage drivers. + +### Breaking + +- Dropped Python 3.9. Building services requires the Python 3.12 `daeploy/s2i-python` builder image (release 0.1.3). + ## 1.3.0 Daeploy goes Open Source and free to use for any purpose! diff --git a/Dockerfile b/Dockerfile index 24644d0..86ff11b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ## Stage 1: Build image -FROM python:3.8 AS build-image +FROM python:3.12 AS build-image # Install S2i RUN wget -c https://github.com/openshift/source-to-image/releases/download/v1.3.0/source-to-image-v1.3.0-eed2850f-linux-amd64.tar.gz \ @@ -20,7 +20,7 @@ COPY ./requirements_manager.txt . RUN pip install -r requirements_manager.txt ## Stage 2: Production image -FROM python:3.8-slim AS production-image +FROM python:3.12-slim AS production-image # Install Git RUN apt-get update && apt-get install -y git diff --git a/README.md b/README.md index 702df3d..e399aac 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ docker run -v /var/run/docker.sock:/var/run/docker.sock -p 80:80 -p 443:443 -e D The application will be available according to the following: -- Docker container listens on ports 80 (http) and 433 (https) -- Proxy listens on ports 5080 (http) (and 5443 (https), NOT fully implemented yet) +- Docker container listens on ports 80 (http) and 443 (https) +- Proxy listens on ports 5080 (http) and 5443 (https) - Manager is available at ```/``` (or locally on port 8000) - Any started services are available at ```/services/{service_name}/``` (or locally on ports 8001 and upwards) - Proxy built-in dashboard is available at ```/proxy/dashboard/``` @@ -133,4 +133,4 @@ Can be found here: [Scope Statement](https://vikinganalytics.sharepoint.com/site ## Architecture Overview -![Architecture Overview](docs/source/content/img/daeploy_architecture.jpg) \ No newline at end of file +![Architecture Overview](docs/source/content/img/daeploy_architecture.jpg) diff --git a/daeploy/_service/db.py b/daeploy/_service/db.py index 5fd852c..fb84e63 100644 --- a/daeploy/_service/db.py +++ b/daeploy/_service/db.py @@ -8,9 +8,9 @@ from contextlib import contextmanager import json -from sqlalchemy import create_engine, and_ +from sqlalchemy import create_engine, and_, MetaData from sqlalchemy.ext.automap import automap_base -from sqlalchemy.orm import sessionmaker, mapper, clear_mappers +from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy import Column, DateTime, Float, Text from daeploy.utilities import get_db_table_limit @@ -20,7 +20,7 @@ SERVICE_DB_PATH = Path("service_db.db") ENGINE = create_engine(f"sqlite:///{str(SERVICE_DB_PATH)}") -Base = automap_base() +Base = declarative_base() Session = sessionmaker(bind=ENGINE) QUEUE = queue.Queue() @@ -69,9 +69,6 @@ def create_new_ts_table(name: str, dtype: Type) -> Type: # Create the actual table MapperClass.__table__.create(ENGINE, checkfirst=True) - # Map everything - mapper(MapperClass, MapperClass.__table__) - LOGGER.info(f"Created new table for variable {name}") return MapperClass @@ -221,16 +218,17 @@ def initialize_db(): global QUEUE QUEUE = queue.Queue() global TABLES - Base.prepare(ENGINE, reflect=True) # Automap any existing tables - TABLES = dict(Base.classes) # Make sure we keep track of the auto-mapped tables + # Reflect any existing tables using automap + AutoBase = automap_base(metadata=MetaData()) + AutoBase.prepare(autoload_with=ENGINE) + TABLES = dict(AutoBase.classes) WRITER_THREAD.start() LOGGER.info("DB started!") def remove_db(): """Remove db""" - global WRITER_THREAD - global QUEUE + global WRITER_THREAD, Base # Stop and join writer thread if alive if WRITER_THREAD.is_alive(): @@ -240,10 +238,16 @@ def remove_db(): # Reset it WRITER_THREAD = threading.Thread(target=_writer, daemon=True) + # Reset tables tracking + TABLES.clear() + # Remove db - SERVICE_DB_PATH.unlink() + ENGINE.dispose() + try: + SERVICE_DB_PATH.unlink() + except FileNotFoundError: + pass - # Reset mappers and metadata object - clear_mappers() - Base.metadata.clear() + # Reset base so new tables get fresh mappers + Base = declarative_base() LOGGER.info("DB has been shut down!") diff --git a/daeploy/_service/service.py b/daeploy/_service/service.py index 754f940..aca252e 100644 --- a/daeploy/_service/service.py +++ b/daeploy/_service/service.py @@ -1,3 +1,4 @@ +import os import asyncio import datetime import functools @@ -13,7 +14,8 @@ from fastapi import Body, Request, FastAPI from fastapi.encoders import jsonable_encoder from fastapi.concurrency import run_in_threadpool -from pydantic import create_model, validate_arguments +from fastapi.middleware.cors import CORSMiddleware +from pydantic import create_model, validate_call from daeploy._service.logger import setup_logging from daeploy._service.db import clean_database, initialize_db, remove_db, write_to_ts @@ -31,7 +33,6 @@ ) from daeploy.communication import notify, Severity - setup_logging() logger = logging.getLogger(__name__) @@ -45,8 +46,29 @@ def _disable_http_logs(path: str): ) +def cors_allowed_origins(): + """assumes allowed origin are passed as a single string separated by ; + Example 'https://origin1.com;https://orogin2.com' + + Returns: + list: url of allowed origins + """ + return os.environ.get("DAEPLOY_ALLOW_ORIGIN", "").split(";") + + +def get_cors_config(): + cors_config = {} + cors_config["allow_credentials"] = False + cors_config["allow_origins"] = cors_allowed_origins() + cors_config["allow_methods"] = ["GET", "POST", "PUT", "DELETE"] + cors_config["allow_headers"] = ["Authorization"] + return cors_config + + class _Service: def __init__(self): + cors_config = get_cors_config() + self.app = FastAPI( root_path=get_service_root_path(), title=f"{get_service_name()} {get_service_version()}", @@ -58,6 +80,10 @@ def __init__(self): ], ) + # Custom Middleware + if bool(os.environ.get("DAEPLOY_ENABLE_CORS")): + self.app.add_middleware(CORSMiddleware, **cors_config) + self.parameters = {} # daeploy-specific setup @@ -192,7 +218,7 @@ async def wrapper(_request: Request, *args, **kwargs): _disable_http_logs(path) # Wrap the original func in a pydantic validation wrapper and return that - return validate_arguments(deco_func) + return validate_call(deco_func) # This ensures that we can use the decorator with or without arguments if not (callable(func) or func is None): @@ -343,7 +369,7 @@ def add_parameter( if isinstance(value, Number): value = float(value) - @validate_arguments() + @validate_call() def update_parameter(value: value.__class__) -> Any: logger.info(f"Parameter {parameter} changed to {value}") self.parameters[parameter]["value"] = value diff --git a/daeploy/cli/cli.py b/daeploy/cli/cli.py index f8de691..55bc5cc 100644 --- a/daeploy/cli/cli.py +++ b/daeploy/cli/cli.py @@ -5,8 +5,7 @@ import os import json -import click -import pkg_resources +from importlib.metadata import version as get_version, PackageNotFoundError import pytest import requests import typer @@ -74,9 +73,9 @@ def version_callback(value: bool): # Get SDK Version try: - sdk_version = pkg_resources.get_distribution("daeploy").version + sdk_version = get_version("daeploy") typer.echo(f"SDK version: {sdk_version}") - except pkg_resources.DistributionNotFound: + except PackageNotFoundError: pass # Get Manager Version @@ -123,7 +122,7 @@ def _callback( """ state = config.CliState() # Skip host and token checks if --help flag is included. - if "--help" in click.get_os_args(): + if "--help" in sys.argv[1:]: return # Skip host and token checks if running login, init or test function. @@ -270,12 +269,12 @@ def deploy( name: str = typer.Argument( ..., help="Name of the new service.", - autocompletion=_autocomplete_service_name, + shell_complete=_autocomplete_service_name, ), version: str = typer.Argument( ..., help="Version of the new service.", - autocompletion=_autocomplete_service_version, + shell_complete=_autocomplete_service_version, ), source: str = typer.Argument( ..., @@ -388,12 +387,12 @@ def ls( name: Optional[str] = typer.Argument( None, help="List services with this name.", - autocompletion=_autocomplete_service_name, + shell_complete=_autocomplete_service_name, ), version: Optional[str] = typer.Argument( None, help="List service with this version.", - autocompletion=_autocomplete_service_version, + shell_complete=_autocomplete_service_version, ), ): """List running services, filtered by name and version. @@ -432,13 +431,13 @@ def logs( name: str = typer.Argument( ..., help="Name of the service to read logs from", - autocompletion=_autocomplete_service_name, + shell_complete=_autocomplete_service_name, ), version: Optional[str] = typer.Argument( None, help="Version of the service to read logs from." " Defaults to the main version of the service", - autocompletion=_autocomplete_service_version, + shell_complete=_autocomplete_service_version, ), tail: Optional[str] = typer.Option( DEFAULT_NUMBER_OF_LOGS, @@ -517,12 +516,12 @@ def kill( name: Optional[str] = typer.Argument( None, help="Name of the service(s) to kill.", - autocompletion=_autocomplete_service_name, + shell_complete=_autocomplete_service_name, ), version: Optional[str] = typer.Argument( None, help="Version of the service to kill.", - autocompletion=_autocomplete_service_version, + shell_complete=_autocomplete_service_version, ), all_: Optional[bool] = typer.Option(False, "--all", "-a", help="Kill all services"), validation: Optional[bool] = typer.Option( @@ -589,12 +588,12 @@ def assign( name: str = typer.Argument( ..., help="Name of version to change main", - autocompletion=_autocomplete_service_name, + shell_complete=_autocomplete_service_name, ), version: str = typer.Argument( ..., help="Version of service to set as main", - autocompletion=_autocomplete_service_version, + shell_complete=_autocomplete_service_version, ), validation: Optional[bool] = typer.Option( False, @@ -655,13 +654,13 @@ def init( raise typer.Exit(1) # Find out which daeploy version that should be used by the service try: - dist = pkg_resources.get_distribution("daeploy") + daeploy_version = get_version("daeploy") daeploy_specifier = ( - str(dist.as_requirement()) - if dist.version != "0.0.0.dev0" - else dist.project_name + f"daeploy=={daeploy_version}" + if daeploy_version != "0.0.0.dev0" + else "daeploy" ) # Use full specificer unless in dev environment, then just go for the latest - except pkg_resources.DistributionNotFound: + except PackageNotFoundError: typer.echo( "`daeploy` package not found, assuming latest version " "should be used for the generated project." diff --git a/daeploy/cli/user.py b/daeploy/cli/user.py index f7083f8..75f1bb4 100644 --- a/daeploy/cli/user.py +++ b/daeploy/cli/user.py @@ -5,7 +5,6 @@ from daeploy.cli import cliutils - app = typer.Typer(help="Collection of user management commands") typer.Option(None, "-p", "--password", expose_value=False) diff --git a/daeploy/data_types.py b/daeploy/data_types.py index b2ec2a3..d55f266 100644 --- a/daeploy/data_types.py +++ b/daeploy/data_types.py @@ -1,20 +1,24 @@ -# pylint: disable=too-many-ancestors +# pylint: disable=too-many-ancestors, unused-argument from typing import Any, List, Dict import numpy as np import pandas as pd +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema class ArrayInput(np.ndarray): """Pydantic compatible data type for numpy ndarray input.""" @classmethod - def __get_validators__(cls): - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + return core_schema.no_info_plain_validator_function(cls.validate) @classmethod - def __modify_schema__(cls, field_schema): - field_schema.update(type="array", items={}) + def __get_pydantic_json_schema__(cls, schema, handler): + return {"type": "array", "items": {}} @classmethod def validate(cls, value: List) -> np.ndarray: @@ -26,12 +30,14 @@ class ArrayOutput(np.ndarray): """Pydantic compatible data type for numpy ndarray output.""" @classmethod - def __get_validators__(cls): - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + return core_schema.no_info_plain_validator_function(cls.validate) @classmethod - def __modify_schema__(cls, field_schema): - field_schema.update(type="array", items={}) + def __get_pydantic_json_schema__(cls, schema, handler): + return {"type": "array", "items": {}} @classmethod def validate(cls, value: np.ndarray) -> List: @@ -43,16 +49,18 @@ class DataFrameInput(pd.DataFrame): """Pydantic compatible data type for pandas DataFrame input.""" @classmethod - def __get_validators__(cls): - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + return core_schema.no_info_plain_validator_function(cls.validate) @classmethod - def __modify_schema__(cls, field_schema): - field_schema.update(type="object") + def __get_pydantic_json_schema__(cls, schema, handler): + return {"type": "object"} @classmethod def validate(cls, value: Dict[str, Any]) -> pd.DataFrame: - # Transform input to ndarray + # Transform input to DataFrame return pd.DataFrame.from_dict(value) @@ -60,14 +68,16 @@ class DataFrameOutput(pd.DataFrame): """Pydantic compatible data type for pandas DataFrame output.""" @classmethod - def __get_validators__(cls): - yield cls.validate + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + return core_schema.no_info_plain_validator_function(cls.validate) @classmethod - def __modify_schema__(cls, field_schema): - field_schema.update(type="object") + def __get_pydantic_json_schema__(cls, schema, handler): + return {"type": "object"} @classmethod def validate(cls, value: pd.DataFrame) -> Dict[str, Any]: - # Transform input to ndarray + # Transform DataFrame to dict return value.to_dict() diff --git a/docs/render_redirect_index_html_file.py b/docs/render_redirect_index_html_file.py index ebd8fc2..b197742 100644 --- a/docs/render_redirect_index_html_file.py +++ b/docs/render_redirect_index_html_file.py @@ -18,13 +18,27 @@ THIS_DIR = Path(__file__).parent BUILD_DIR = (THIS_DIR / "build" / "html").resolve() -versions = filter(lambda x: x.is_dir(), BUILD_DIR.iterdir()) -versions = map(lambda x: version.parse(x.name), versions) +def _as_release(name): + """Parse a build-dir name as a release version. -versions = filter(lambda v: not (v.is_prerelease or v.is_devrelease), versions) + Returns None for non-version dirs (e.g. ``develop``) and for + pre-/dev-releases, so they are excluded from the redirect target. + Newer ``packaging`` raises ``InvalidVersion`` instead of returning a + legacy version, so the parse must be guarded. + """ + try: + parsed = version.parse(name) + except version.InvalidVersion: + return None + if parsed.is_prerelease or parsed.is_devrelease: + return None + return parsed -versions = sorted(versions, reverse=True) + +dir_names = [p.name for p in BUILD_DIR.iterdir() if p.is_dir()] + +versions = sorted(filter(None, (_as_release(name) for name in dir_names)), reverse=True) the_version = str(versions[0]) diff --git a/docs/source/content/advanced_tutorials/adv_deploy.rst b/docs/source/content/advanced_tutorials/adv_deploy.rst index 64021e9..279bc53 100644 --- a/docs/source/content/advanced_tutorials/adv_deploy.rst +++ b/docs/source/content/advanced_tutorials/adv_deploy.rst @@ -165,7 +165,7 @@ Daeploy (`github link `_ for a full list of available tags. -By default Daeploy uses a builder image based on ubuntu with python 3.8. There +By default Daeploy uses a builder image based on python 3.12. There can be situations, however, where that image might not be suitable. For example if you want to use some library that is not supported for that version of python, or if you need a certain OS. diff --git a/docs/source/content/advanced_tutorials/manager_configuration.rst b/docs/source/content/advanced_tutorials/manager_configuration.rst index 3aff44a..14d0bcc 100644 --- a/docs/source/content/advanced_tutorials/manager_configuration.rst +++ b/docs/source/content/advanced_tutorials/manager_configuration.rst @@ -37,6 +37,10 @@ The Manager is highly configurable using environment variables. +--------------------------------------------+-----------------------+-------------------------------------------------------------+ | DAEPLOY_ADMIN_PASSWORD | admin | Password for the admin user. Defualt to admin. | +--------------------------------------------+-----------------------+-------------------------------------------------------------+ +| DAEPLOY_ENBALE_CORS | False | Enables CORS functionality. | ++--------------------------------------------+-----------------------+-------------------------------------------------------------+ +| DAEPLOY_ALLOW_ORIGIN | null | Allowed origins as string separated by ; | ++--------------------------------------------+-----------------------+-------------------------------------------------------------+ User Management --------------- diff --git a/docs/source/content/getting_started/getting_started.rst b/docs/source/content/getting_started/getting_started.rst index c49a743..5b46e5f 100644 --- a/docs/source/content/getting_started/getting_started.rst +++ b/docs/source/content/getting_started/getting_started.rst @@ -11,7 +11,7 @@ applications. Prerequisites ------------- -To use Daeploy, you need to have **python >= 3.6** installed in your development environment +To use Daeploy, you need to have **python >= 3.10** installed in your development environment and **docker** in your deployment environment. Installation diff --git a/docs/superpowers/plans/2026-06-21-ui-redesign.md b/docs/superpowers/plans/2026-06-21-ui-redesign.md new file mode 100644 index 0000000..bad7e13 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-ui-redesign.md @@ -0,0 +1,571 @@ +# Daeploy UI Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reskin Daeploy's login page and Dash dashboard into a modern-dark "control plane" UI, and add a streaming logs view with a Follow/auto-scroll toggle — fully self-contained (no CDNs). + +**Architecture:** Keep the existing stack. Login and the new logs view are FastAPI-served Jinja2 templates; the dashboard stays a Plotly Dash app whose layout is built in Python (`manager/routers/dashboard_api.py`) and styled by CSS in `manager/assets/` (Dash auto-loads `assets/*.css`). A shared `manager/assets/tokens.css` (color/type tokens + bundled `@font-face`) is loaded by all three surfaces. A new `/assets` static mount serves tokens, fonts, and the logo SVG to the Jinja pages. The logs view is a thin HTML shell that `fetch`-streams the existing `GET /services/~logs?...&follow=true` `text/plain` endpoint and renders lines client-side. + +**Tech Stack:** FastAPI, Jinja2, Plotly Dash 4.1, Starlette `StaticFiles`, vanilla CSS + JS (no build step, no external libraries). + +**Canonical visual reference:** `docs/superpowers/specs/2026-06-21-ui-redesign.mockup.html` (the approved mockup, committed). All exact CSS rules, markup structure, the sonar-canvas script, and the logs streaming/Follow JS are taken **verbatim** from it; this plan says which slice of the mockup goes into which file and supplies all the FastAPI/Dash integration code that the mockup (a single static file) doesn't contain. When a step says "copy from the mockup", open that file and copy the named block exactly. + +**Design spec:** `docs/superpowers/specs/2026-06-21-ui-redesign-design.md`. + +## Global Constraints + +- **No external network dependencies in the UI.** No CDN ``/` + + +``` +Keep the `{{ ACTION }}` form exactly. The mark is now `` (the mockup used inline SVG; either is fine — use the img so it shares the asset). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py -k "login or assets" -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add manager/app.py manager/templates/login.html tests/manager_test/test_ui_redesign.py +git commit -m "Reskin login page; serve bundled assets via /assets static mount" +``` + +--- + +## Task 3: Reskin the dashboard (CSS + Dash layout) + +**Files:** +- Rewrite: `manager/assets/dashboard_styles.css` +- Modify: `manager/routers/dashboard_api.py` (layout-building functions only — NOT callbacks) +- Test: `tests/manager_test/test_ui_redesign.py` + +**Interfaces:** +- Consumes: tokens.css (auto-loaded by Dash from `assets_folder="../assets"`). +- Produces: restyled dashboard. `generate_table_services`, `generate_table_notifications`, `build_banner`, `build_user_section` keep their **names and call signatures**; only the returned component tree + classNames change. `update_content` and the clear-notifications callback are unchanged. + +- [ ] **Step 1: Write the failing tests** + +```python +# append to tests/manager_test/test_ui_redesign.py +def test_dashboard_css_uses_tokens(): + css = (ASSETS / "dashboard_styles.css").read_text() + assert "var(--ground)" in css and "var(--accent)" in css + assert "http://" not in css and "https://" not in css + +def test_dashboard_layout_builds(): + # importing must not raise and layout must be present + from manager.routers import dashboard_api + assert dashboard_api.app.layout is not None + # helper functions still exist with the same names + for fn in ["generate_table_services", "generate_table_notifications", + "build_banner", "build_user_section", "update_content"]: + assert hasattr(dashboard_api, fn) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py -k dashboard -v` +Expected: `test_dashboard_css_uses_tokens` FAILS (old CSS has hard-coded hex, no `var(--…)`); `test_dashboard_layout_builds` may already pass (acceptable — it guards the refactor in Step 4). + +- [ ] **Step 3: Rewrite `manager/assets/dashboard_styles.css`** + +Replace the whole file. Port the mockup's **DASHBOARD** CSS section (`.top`, `.vchip`, `.actions`, `.act`, `.page`, `.grid`, `.panel*`, `.svc*`, `.pin*`, `.state*`, `.sdot*`, `.badge*`, `.lnk`, `.note*`, `.sev*`, `.empty`, `@keyframes pulse`, the responsive `@media` block) verbatim, plus a `body`/links base that uses the tokens: +```css +body{background:var(--ground);color:var(--text);font-family:var(--sans);margin:0;overflow-x:hidden;} +a{color:inherit;text-decoration:none;} +/* …then the ported dashboard rules from the mockup, unchanged… */ +``` +Delete the old `.banner`, `#big-app-container`, `#app-container`, `.daeploy_custom-tab*`, `tr:nth-child`, `.logout`, `.severity-*`, `.user-actions` rules (their roles are replaced below). + +- [ ] **Step 4: Rewrite the layout helpers in `manager/routers/dashboard_api.py`** + +Keep all imports, `app = dash.Dash(...)`, callbacks, and `read_services`/`inspect_service` usage. Replace the component-building functions so the tree matches the mockup's dashboard markup. Concretely: + +`build_banner()` → returns the top bar (mockup `.top`): logo `html.Img(src=app.get_asset_url("daeploy_mark.svg"))` + wordmark + `html.Span("manager v: ", className="vchip")`. + +`build_user_section()` → returns the `.actions` nav: `html.A("Logs", …, className="act")`, `html.A("API Docs", …, className="act")`, `html.Button("Clear notifications", id="clear-notifications-button", n_clicks=0, className="act")`, `html.A("Log out", …, className="act danger")`. **Keep `id="clear-notifications-button"`** (the callback depends on it). + +`generate_table_services()` → for each service, build a `.svc` row Div with: status `.sdot` (`run`/`run live` if main running, `shadow`, or `stop`), name + `.ver` version (mono), `.pin main` ★ for main else `.pin` ○/↗, state label + `.since` (reuse `get_service_state`), and a `.svc-actions` Div with the Logs link (Task 4 view URL) + Docs link. + +`generate_table_notifications()` → for each notification build a `.note` row: `.sev info|warn|crit` rule (map severity 0/1/2), `.msg` message, `.meta` with severity tag + timestamp. Reuse the existing severity mapping in `get_severity_colors` (0=Info,1=Warning,2=Critical). + +`app.layout` → wrap in the page structure: keep `dcc.Interval(id="interval1", interval=5*1000, n_intervals=0)`; render `build_banner()`, `build_user_section()`, then a `.page` > `.grid` containing a Services `.panel` (header "Services" + `html.Div(id="app-content")`) and a Notifications `.panel`. **Keep `id="app-content"`** (the `update_content` callback targets it). Wrap classNames per the mockup. + +Use exact classNames from the mockup so the new CSS applies. Do not change `update_content`, the `@app.callback` decorators, or the clear-notifications logic. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py -k dashboard -v` +Expected: PASS. Also run the existing dashboard guard: +Run: `./venv/bin/python -m pytest tests/manager_test -k "dashboard or endpoint" -v` +Expected: no new failures vs. baseline (pre-existing env failures from [[daeploy-local-test-env-caveats]] excepted). + +- [ ] **Step 6: Commit** + +```bash +git add manager/assets/dashboard_styles.css manager/routers/dashboard_api.py tests/manager_test/test_ui_redesign.py +git commit -m "Reskin dashboard: token-based styles and control-plane layout" +``` + +--- + +## Task 4: Streaming logs view with Follow toggle + +**Files:** +- Create: `manager/templates/logs.html` +- Modify: `manager/routers/service_api.py` (add HTML view route) +- Modify: `manager/routers/dashboard_api.py` (`get_service_log_link` → point at the view) +- Test: `tests/manager_test/test_ui_redesign.py` + +**Interfaces:** +- Consumes: existing `GET /services/~logs?name&version&tail&follow&since` (`StreamingResponse`, `text/plain`); tokens.css at `/assets/tokens.css`. +- Produces: `GET /services/~logs/view?name=&version=` → HTML page (status 200) that streams the above endpoint. The dashboard service "Logs" link now points here. + +- [ ] **Step 1: Write the failing tests** + +```python +# append to tests/manager_test/test_ui_redesign.py +def test_logs_view_route_returns_page(test_client_logged_in): + r = test_client_logged_in.get("/services/~logs/view?name=demo&version=0.1.0") + assert r.status_code == 200 + body = r.text + assert 'id="console"' in body + assert 'id="followBox"' in body # the Follow checkbox + assert "/services/~logs?" in body # streams the real endpoint + assert "name=demo" in body and "version=0.1.0" in body + +def test_logs_view_template_self_contained(): + html = TPL.joinpath("logs.html").read_text() + low = html.lower() + for bad in FORBIDDEN: + assert bad not in low + assert "/assets/tokens.css" in html +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py -k logs_view -v` +Expected: FAIL (route + template do not exist). + +- [ ] **Step 3: Create `manager/templates/logs.html`** + +Self-contained shell. Port the mockup's **LOGS** CSS (`.logs-head`, `.live-tag*`, `.follow*`, `.track`, `.console*`, `.logline*`, `.jump*`) and the logs markup (`.panel` with `.logs-head` + `.console-wrap` > `#console` + `#jumpBtn`). Replace the mockup's fake `POOL`/`appendLine` generator with a **real fetch-stream reader** of the `~logs` endpoint, keeping the same Follow/auto-scroll/jump logic verbatim. Read the target from template context: + +```html + + + + + Daeploy — {{ name }} logs + + + + + +
+
+
+
+
+ {{ name }}v{{ version }}
+
+ Live + +
+
+
+
+ +
+
+
+ + + +``` + +- [ ] **Step 4: Add the HTML view route in `manager/routers/service_api.py`** + +Add near the top (after the existing imports): +```python +from fastapi import Request +from fastapi.templating import Jinja2Templates +TEMPLATES = Jinja2Templates(directory="manager/templates") +``` +Add the route (place it just above the existing `@ROUTER.get("/~logs", ...)`): +```python +@ROUTER.get("/~logs/view", response_class=HTMLResponse) +def service_logs_view(request: Request, name: str, version: str): + """HTML view that streams a service's logs with a follow/auto-scroll toggle.""" + return TEMPLATES.TemplateResponse( + "logs.html", {"request": request, "name": name, "version": version} + ) +``` +Ensure `HTMLResponse` is imported (`from fastapi.responses import HTMLResponse, StreamingResponse`). This route deliberately does **not** use the `@async_check_service_exists_query_parameters` decorator — it only renders the shell; the `~logs` stream it calls already enforces existence. + +- [ ] **Step 5: Point the dashboard "Logs" link at the view** + +In `manager/routers/dashboard_api.py`, change `get_service_log_link` to: +```python +def get_service_log_link(service): + proxy_url = get_external_proxy_url() + return html.A( + "Logs", + href=f"{proxy_url}/services/~logs/view" + f"?name={service['name']}&version={service['version']}", + className="lnk", + ) +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py -k logs_view -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add manager/templates/logs.html manager/routers/service_api.py manager/routers/dashboard_api.py tests/manager_test/test_ui_redesign.py +git commit -m "Add streaming logs view with Follow/auto-scroll toggle" +``` + +--- + +## Task 5: Offline + visual verification in the running manager + +**Files:** none (verification task). + +- [ ] **Step 1: Run the full new test module** + +Run: `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py -v` +Expected: all PASS. + +- [ ] **Step 2: Lint the changed files (CI gates)** + +Run: `/home/kaveh/miniconda3/bin/python -m black --check manager/ tests/manager_test/test_ui_redesign.py` +Run: `/home/kaveh/miniconda3/bin/python -m flake8 manager/routers/service_api.py manager/routers/dashboard_api.py` +Expected: black clean; no *new* flake8 findings. + +- [ ] **Step 3: Build and run the manager from this branch** + +```bash +docker build -t daeploy/manager:latest . +docker rm -f daeploy-manager 2>/dev/null +docker run -d --name daeploy-manager -v /var/run/docker.sock:/var/run/docker.sock \ + -p 80:80 -p 443:443 -e DAEPLOY_AUTH_ENABLED=True -e DAEPLOY_HOST_NAME=localhost \ + -e DAEPLOY_ADMIN_PASSWORD=admin123 daeploy/manager:latest +``` + +- [ ] **Step 4: Visually verify (browser at http://localhost)** + +- Login renders dark with the sonar backdrop, teal "Log in"; logging in with `admin`/`admin123` works. +- Dashboard shows the restyled top bar, service rows with status dots/badges, and the notifications panel. +- Deploy a sample service and open its **Logs** link → the logs view streams; toggling **Follow** off lets you scroll history and shows **Jump to latest**; toggling on resumes auto-scroll. + +- [ ] **Step 5: Confirm no external requests (offline guarantee)** + +In the browser DevTools Network tab, reload login and the dashboard and confirm **every** request is same-origin (`localhost`) — no fonts/CSS/JS/images from any CDN or `daeploy.com`. + +- [ ] **Step 6: Tear down** + +```bash +docker rm -f daeploy-manager +``` + +- [ ] **Step 7: Commit any verification-driven fixes** + +```bash +git add -A && git commit -m "UI redesign: verification fixes" # only if changes were needed +``` + +--- + +## Self-Review + +**Spec coverage:** self-contained/no-CDN → Task 1 (tokens/fonts), Task 2 (login + `/assets` mount), Task 5 Step 5 (offline check). Login reskin → Task 2. Dashboard reskin (top bar, service rows, notifications) → Task 3. Logs view + Follow/pause/jump → Task 4. Bundled fonts → Task 1. Logo de-hotlink → Task 1 + Tasks 2/4. Color tokens verbatim → Global Constraints + Task 1. Keep stack/behavior → enforced in Tasks 2–4 (form contract, callback ids, helper names). Verification → Task 5. + +**Placeholder scan:** the "copy from the mockup" instructions point to a committed, complete reference file with named blocks — not vague TODOs. All integration code (static mount, routes, fonts, streaming JS, link change) is given in full. No "add error handling"/"TBD" left. + +**Type/name consistency:** callback-critical ids preserved exactly — `clear-notifications-button`, `app-content`, `interval1`. Helper functions keep names (`generate_table_services`, `generate_table_notifications`, `build_banner`, `build_user_section`, `get_service_state`, `get_service_log_link`). New route `GET /services/~logs/view?name&version` matches the link built in Task 4 Step 5 and the test in Step 1. Stream URL params (`name`, `version`, `follow`, `tail`) match the existing `read_service_logs` signature. diff --git a/docs/superpowers/plans/2026-06-29-dashboard-main-page-polish.md b/docs/superpowers/plans/2026-06-29-dashboard-main-page-polish.md new file mode 100644 index 0000000..bf91e15 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-dashboard-main-page-polish.md @@ -0,0 +1,307 @@ +# Dashboard Main-Page Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Polish the manager dashboard so Services is the clear hero, Notifications is a slim self-explanatory rail, LOGS/DOCS read as buttons, and short content no longer leaves a top-heavy void. + +**Architecture:** Frontend-only. Two files: `manager/routers/dashboard_api.py` (Dash layout markup — one added subtitle element) and `manager/assets/dashboard_styles.css` (all styling). Guard tests are string-assert checks in `tests/manager_test/test_ui_redesign.py`, matching the existing pattern (read the CSS file / `str(app.layout)` and assert substrings). + +**Tech Stack:** Dash (Plotly) layout in Python, plain CSS with design tokens from `tokens.css`, pytest guard tests via `test_client` fixtures in `tests/conftest.py`. + +## Global Constraints + +- No backend, API, endpoint, or data-model changes. CSS + one markup element only. +- No external resources: CSS must contain no `http://` or `https://` (enforced by `test_dashboard_css_uses_tokens`). +- Reuse existing design tokens from `tokens.css` (`var(--muted)`, `var(--line)`, `var(--accent)`, etc.) — introduce no new colors. +- The logs view page (`logs.html`), login, and `max-width:1600px` on `.page` stay as-is. Do not reintroduce `max-width:1180px` (guarded by `test_dashboard_page_width_widened`). +- Run before pushing: `/home/kaveh/miniconda3/bin/python -m black --check`, `... -m flake8`, `... -m pylint manager`, and `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py`. +- Mobile rule `@media (max-width:760px){ .grid{grid-template-columns:1fr} ... }` must keep collapsing the grid to one column. + +--- + +### Task 1: Widen Services / slim sidebar + vertical fill + +**Files:** +- Modify: `manager/assets/dashboard_styles.css` (the `.page` rule at line 32 and the `.grid` rule at line 33) +- Test: `tests/manager_test/test_ui_redesign.py` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: the `.grid` / `.page` CSS shape that Task 2 and Task 3 visually sit inside; no code symbols. + +Current CSS (lines 31-33): + +```css +/* ---------- page / grid ---------- */ +.page{max-width:1600px;margin:0 auto;padding:1.8rem 1.6rem 4rem} +.grid{display:grid;grid-template-columns:1.85fr 1fr;gap:1.4rem;align-items:start} +``` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/manager_test/test_ui_redesign.py`: + +```python +def test_dashboard_grid_widens_services_and_fills_viewport(): + css = (ASSETS / "dashboard_styles.css").read_text().replace(" ", "") + # Services hero is wider than the slim Notifications rail + assert "grid-template-columns:2.6fr0.9fr" in css + assert "grid-template-columns:1.85fr1fr" not in css + # Page fills the viewport height (minus the banner) and centers when short + assert "min-height:calc(100vh-4rem)" in css + assert "margin-block:auto" in css + assert "box-sizing:border-box" in css +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./venv/bin/python -m pytest "tests/manager_test/test_ui_redesign.py::test_dashboard_grid_widens_services_and_fills_viewport" -v` +Expected: FAIL (`assert "grid-template-columns:2.6fr0.9fr" in css` is False — file still has `1.85fr 1fr`). + +- [ ] **Step 3: Write minimal implementation** + +Replace lines 31-33 of `manager/assets/dashboard_styles.css` with: + +```css +/* ---------- page / grid ---------- */ +.page{ + max-width:1600px;margin:0 auto;padding:1.8rem 1.6rem 4rem; + box-sizing:border-box;min-height:calc(100vh - 4rem); + display:flex;flex-direction:column; +} +.grid{ + display:grid;grid-template-columns:2.6fr 0.9fr;gap:1.4rem;align-items:start; + margin-block:auto;width:100%; +} +``` + +Notes for the implementer: +- `vh` is viewport-relative, so `min-height:calc(100vh - 4rem)` makes `.page` nearly fill the screen without touching `body` or Dash's wrapper divs. The `4rem` budgets for the top banner. +- `box-sizing:border-box` is required because there is no global reset and `.page` has padding; without it the padding would add to the `100vh` height and force a scrollbar. +- `margin-block:auto` on `.grid` centers it vertically inside the tall `.page` when there is spare room; when the grid is taller than the page the auto margins collapse to 0 and the page grows/scrolls normally. +- `width:100%` keeps the grid full-width inside the flex column (a flex item would otherwise shrink to content width). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./venv/bin/python -m pytest "tests/manager_test/test_ui_redesign.py::test_dashboard_grid_widens_services_and_fills_viewport" -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add manager/assets/dashboard_styles.css tests/manager_test/test_ui_redesign.py +git commit -m "Dashboard: widen Services, slim sidebar, fill viewport height" +``` + +--- + +### Task 2: Explain the Notifications panel + +**Files:** +- Modify: `manager/routers/dashboard_api.py` (the Notifications panel head in `app.layout`, around lines 334-340) +- Modify: `manager/assets/dashboard_styles.css` (add `.panel-sub` near the `.panel-head` rules, ~line 44) +- Test: `tests/manager_test/test_ui_redesign.py` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: a `

` element with the literal subtitle text below; relied on only by its own test. + +Current markup (lines 334-345 of `dashboard_api.py`): + +```python + # Notifications panel + html.Section( + className="panel", + children=[ + html.Div( + className="panel-head", + children=[html.H2("Notifications")], + ), + html.Div( + id="notifications-content", + children=[generate_table_notifications()], + ), + ], + ), +``` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/manager_test/test_ui_redesign.py`: + +```python +def test_notifications_panel_has_explanation(): + from manager.routers import dashboard_api + + layout = str(dashboard_api.app.layout) + assert "Alerts services raise" in layout # one-line description of the panel + css = (ASSETS / "dashboard_styles.css").read_text() + assert ".panel-sub" in css +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./venv/bin/python -m pytest "tests/manager_test/test_ui_redesign.py::test_notifications_panel_has_explanation" -v` +Expected: FAIL (`assert "Alerts services raise" in layout` is False — no subtitle yet). + +- [ ] **Step 3: Write minimal implementation** + +In `manager/routers/dashboard_api.py`, change the Notifications `panel-head` children to include a subtitle (replace the single-child `panel-head` Div shown above): + +```python + html.Div( + className="panel-head", + children=[ + html.Div( + [ + html.H2("Notifications"), + html.P( + "Alerts services raise via the SDK" + " notify() — info, warnings, critical.", + className="panel-sub", + ), + ] + ) + ], + ), +``` + +Then add the `.panel-sub` style to `manager/assets/dashboard_styles.css` immediately after the `.panel-head h2` rule (line 44): + +```css +.panel-sub{margin:.25rem 0 0;font-size:11px;line-height:1.4;color:var(--muted)} +``` + +Note: keep the `()` in `notify()` but do not include any URL — the CSS/markup must stay free of `http(s)://` (Task constraint). The text uses a plain hyphen. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./venv/bin/python -m pytest "tests/manager_test/test_ui_redesign.py::test_notifications_panel_has_explanation" -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add manager/routers/dashboard_api.py manager/assets/dashboard_styles.css tests/manager_test/test_ui_redesign.py +git commit -m "Dashboard: explain the Notifications panel with a subtitle" +``` + +--- + +### Task 3: Make LOGS / DOCS read as buttons + +**Files:** +- Modify: `manager/assets/dashboard_styles.css` (the `.lnk` rules at lines 90-95) +- Test: `tests/manager_test/test_ui_redesign.py` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: restyled `.lnk` (markup unchanged — `get_service_log_link` / `get_service_docs_link` still emit ``). + +Current CSS (lines 88-95): + +```css +/* ---------- service actions ---------- */ +.svc-actions{grid-area:actions;display:flex;gap:.4rem} +.lnk{ + font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase; + color:var(--muted);text-decoration:none;border-bottom:1px solid transparent;padding:.1rem 0; + transition:color .15s,border-color .15s; +} +.lnk:hover{color:var(--accent);border-color:var(--accent)} +``` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/manager_test/test_ui_redesign.py`: + +```python +def test_service_links_look_like_buttons(): + css = (ASSETS / "dashboard_styles.css").read_text().replace(" ", "") + # .lnk is now a bordered pill, not underline-on-hover text + assert ".lnk{" in css + lnk_block = css.split(".lnk{", 1)[1].split("}", 1)[0] + assert "border:1px solid" in lnk_block + assert "border-radius:" in lnk_block + # keyboard focus ring, matching .act + assert ".lnk:focus-visible{" in css +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./venv/bin/python -m pytest "tests/manager_test/test_ui_redesign.py::test_service_links_look_like_buttons" -v` +Expected: FAIL (the current `.lnk` block has `border-bottom`, not `border:1px solid`, and no `:focus-visible`). + +- [ ] **Step 3: Write minimal implementation** + +Replace lines 90-95 of `manager/assets/dashboard_styles.css` with: + +```css +.lnk{ + font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase; + color:var(--muted);text-decoration:none; + border:1px solid var(--line);border-radius:7px;padding:.34rem .6rem; + transition:color .15s,border-color .15s,background .15s; +} +.lnk:hover{color:var(--accent);border-color:var(--accent-dim);background:rgba(94,230,208,.06)} +.lnk:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +``` + +Note: this mirrors the existing `.act` button look (border + rounded + accent hover + focus ring) so the two LOGS/DOCS controls clearly read as clickable. `.svc-actions` already has `gap:.4rem`, which spaces the two pills correctly — no change needed there. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./venv/bin/python -m pytest "tests/manager_test/test_ui_redesign.py::test_service_links_look_like_buttons" -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add manager/assets/dashboard_styles.css tests/manager_test/test_ui_redesign.py +git commit -m "Dashboard: style LOGS/DOCS as buttons with focus ring" +``` + +--- + +### Task 4: Full guard-suite + lint pass + +**Files:** +- No new code. Verification only. + +- [ ] **Step 1: Run the full UI guard suite** + +Run: `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py -v` +Expected: PASS (all existing tests + the three new ones). Confirms no earlier guard (e.g. `test_dashboard_layout_builds`, `test_dashboard_page_width_widened`, `test_notifications_panel_is_live`) regressed. + +- [ ] **Step 2: Lint the changed files** + +Run: +```bash +/home/kaveh/miniconda3/bin/python -m black --check manager/routers/dashboard_api.py tests/manager_test/test_ui_redesign.py +/home/kaveh/miniconda3/bin/python -m flake8 manager/routers/dashboard_api.py tests/manager_test/test_ui_redesign.py +/home/kaveh/miniconda3/bin/python -m pylint manager +``` +Expected: black "would be left unchanged", flake8 no output, pylint 10.00/10. (CSS is not linted.) If black reports a reformat on `dashboard_api.py`, run it without `--check` to apply, then re-commit. + +- [ ] **Step 3: Commit any formatting fixup (only if Step 2 changed files)** + +```bash +git add -A +git commit -m "Dashboard polish: black formatting" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Widen Services / slim sidebar → Task 1 (`2.6fr 0.9fr`). ✓ +- Explain Notifications → Task 2 (`panel-sub` subtitle). ✓ +- LOGS/DOCS obvious → Task 3 (bordered buttons + focus ring). ✓ +- Fix top-heavy emptiness → Task 1 (`min-height:calc(100vh - 4rem)` + `margin-block:auto`). ✓ +- Testing section (guard-test asserts) → Tasks 1-3 each add one test; Task 4 runs the full suite + lint. ✓ +- Non-goals (no stats bar, no badges, no logs-view change, no backend) → respected; no task touches them. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows full CSS/markup and exact commands. ✓ + +**Type/string consistency:** The subtitle literal "Alerts services raise via the SDK notify() — info, warnings, critical." in Task 2's implementation matches the test's substring "Alerts services raise". The class names `.panel-sub`, `.lnk`, `.grid`, `.page` are used identically across implementation and tests. The `2.6fr 0.9fr` value (space-stripped to `2.6fr0.9fr` in tests) is consistent. ✓ diff --git a/docs/superpowers/plans/2026-06-29-logs-viewer-enhancements.md b/docs/superpowers/plans/2026-06-29-logs-viewer-enhancements.md new file mode 100644 index 0000000..ece6208 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-logs-viewer-enhancements.md @@ -0,0 +1,518 @@ +# Logs Viewer Enhancements + Responsive Layout — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the logs view a full-screen responsive console with log export and in-log search (over full history), and let the dashboard use more of the screen. + +**Architecture:** Pure front-end + two tiny route-context additions; no backend changes. The shared `manager/templates/logs.html` (serves both per-service `/services/~logs/view` and manager `/logs/view`) gains a responsive flex layout, an Export button, and a Search box. Export/search fetch the full log via the existing endpoints with `tail=all`. Dashboard width is a one-line CSS change. + +**Tech Stack:** FastAPI + Jinja2 templates, Plotly Dash dashboard, vanilla JS + CSS (no build step, no libraries). + +**Spec:** `docs/superpowers/specs/2026-06-29-logs-viewer-enhancements-design.md` + +## Global Constraints + +- **No backend changes.** Export/search use existing endpoints: service `GET /services/~logs?name=&version=&tail=all`; manager `GET /logs/stream?tail=all`. The live tail keeps `tail=200`/`tail=400` + `follow=true`. +- **Reuse the shared `manager/templates/logs.html`** — both log views get every feature. +- **Self-contained** (no CDNs); use the existing design tokens (`var(--accent)`, `var(--ground)`, etc.). +- **XSS-safe log rendering**: insert log text via `textContent` / `createTextNode` — never `innerHTML` on log content (search highlight uses `` element nodes around text nodes). +- **Preserve the live tail**: streaming + Follow/auto-scroll/Jump-to-latest stay; new features layer on top. +- **Dashboard width:** `max-width: 1600px` centered (not fully fluid). + +## File Structure + +- `manager/assets/dashboard_styles.css` — **(modify)** `.page` max-width 1180 → 1600. +- `manager/templates/logs.html` — **(modify)** responsive flex layout CSS; toolbar gains Search + Export; JS for cancellable live stream, search (filter/highlight/count), export (download). +- `manager/routers/service_api.py` — **(modify)** `service_logs_view`: add `full_url` + `export_basename` to context. +- `manager/routers/logging_api.py` — **(modify)** `manager_logs_view`: add `full_url` + `export_basename` to context. +- `tests/manager_test/test_ui_redesign.py` — **(modify)** add guard tests. + +Run tests with `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py -v` from the repo root. Lint with `/home/kaveh/miniconda3/bin/python -m black --check ` and `/home/kaveh/miniconda3/bin/python -m pylint manager daeploy`. + +--- + +## Task 1: Widen the dashboard + +**Files:** +- Modify: `manager/assets/dashboard_styles.css:32` +- Test: `tests/manager_test/test_ui_redesign.py` + +- [ ] **Step 1: Write the failing test** (append to `tests/manager_test/test_ui_redesign.py`) + +```python +def test_dashboard_page_width_widened(): + css = (ASSETS / "dashboard_styles.css").read_text().replace(" ", "") + assert "max-width:1600px" in css + assert "max-width:1180px" not in css +``` + +- [ ] **Step 2: Run it, expect FAIL** + +Run: `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py::test_dashboard_page_width_widened -v` +Expected: FAIL (current value is `1180px`). + +- [ ] **Step 3: Make the change** — in `manager/assets/dashboard_styles.css`, replace line 32: + +```css +.page{max-width:1180px;margin:0 auto;padding:1.8rem 1.6rem 4rem} +``` +with: +```css +.page{max-width:1600px;margin:0 auto;padding:1.8rem 1.6rem 4rem} +``` + +- [ ] **Step 4: Run it, expect PASS** + +Run: `./venv/bin/python -m pytest tests/manager_test/test_ui_redesign.py::test_dashboard_page_width_widened -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add manager/assets/dashboard_styles.css tests/manager_test/test_ui_redesign.py +git commit -m "Widen dashboard page to 1600px" +``` + +--- + +## Task 2: Responsive full-screen logs layout + +**Files:** +- Modify: `manager/templates/logs.html` (CSS in the ` + + +

+ +
+ + +
+
+
+
+ + daeploy +
+ manager v: latest +
+
+
+ +
+
+ + +
+
+

Services

+ 4 deployed · 3 running +
+ +
+
+ +
+
status_code
+
v0.1.0
+
+
+
+ +
+
Running
+
since 2026-06-21 17:24:13
+
+
+
+ Logs + Docs +
+
+ +
+
+ +
+
forecast-api
+
v1.2.0
+
+
+
+ +
+
Running
+
since 2026-06-20 09:11:02
+
+
+
+ Logs + Docs +
+
+ +
+
+ +
+
forecast-api shadow
+
v1.3.0
+
+
+
+ +
+
Mirroring traffic
+
since 2026-06-21 14:02:55
+
+
+
+ Logs + Docs +
+
+ +
+
+ +
+
anomaly-detector
+
v0.4.1
+
+
+
+ +
+
Stopped
+
since 2026-06-19 22:48:10
+
+
+
+ Logs + Docs +
+
+
+ + +
+
+

Notifications

+ 3 +
+ +
+ +
+
anomaly-detector stopped after 3 failed restarts.
+
CriticalYesterday · 22:49
+
+
+
+ +
+
anomaly-detector exited with code 1 and was restarted.
+
WarningYesterday · 22:48
+
+
+
+ +
+
forecast-api v1.3.0 deployed as a shadow of v1.2.0.
+
InfoToday · 14:02
+
+
+
+ +
+
+
+ + +
+
+
+
+ + daeploy +
+ manager v: latest +
+ +
+ +
+
+
+
+ + status_code + v0.1.0 +
+
+ Live + +
+
+ +
+
+ +
+
+
+
+ + +
+ + + +
+ + diff --git a/docs/superpowers/specs/2026-06-29-dashboard-main-page-polish-design.md b/docs/superpowers/specs/2026-06-29-dashboard-main-page-polish-design.md new file mode 100644 index 0000000..111829f --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-dashboard-main-page-polish-design.md @@ -0,0 +1,97 @@ +# Dashboard main-page polish — design + +**Date:** 2026-06-29 +**Branch:** `dashboard-main-page-polish` → `develop` +**Scope:** Frontend-only polish of the manager dashboard (the Dash "main page"). No backend, no API, no new data. The logs *view* page is explicitly out of scope (unchanged). + +## Problem + +On a wide monitor the dashboard reads top-heavy and sparse: content hugs the top with a large empty void below. Secondary issues raised by the user: + +1. The **Services** panel feels too narrow relative to its importance. +2. The **Notifications** panel is unexplained — an empty "No notifications." reads as "what is this section?" rather than "nothing wrong." +3. The per-service **LOGS / DOCS** links are not obviously clickable (tiny uppercase text, underline only on hover). + +## Goals + +- Make Services the clear hero (wider) and Notifications a slim, self-explanatory right rail. +- Remove the top-heavy empty void without inventing new content. +- Make LOGS / DOCS read as actionable controls. + +## Non-goals (YAGNI) + +- No new stats/summary bar, service counts, or notification badges. +- No structural rewrite of the Dash layout or callbacks. +- No change to the logs view page, login, or any backend/endpoint. + +## Design + +All changes are in `manager/routers/dashboard_api.py` (layout markup) and +`manager/assets/dashboard_styles.css` (styling). The design tokens in +`tokens.css` are reused; no new colors. + +### 1. Widen Services, slim the sidebar +`.grid` columns change from `1.85fr 1fr` to **`2.6fr 0.9fr`**. Services gets +the dominant width; Notifications becomes a narrow rail. The existing +`@media (max-width:760px)` rule already collapses to a single column, so the +mobile path is unchanged. + +### 2. Explain Notifications +Add a one-line muted subtitle beneath the "Notifications" heading in the panel +head, e.g.: + +> Alerts services raise via the SDK `notify()` — info, warnings, critical. + +Implemented as a small `

` element in the Notifications +panel head. A new `.panel-sub` style (muted, ~11px) renders it quietly. The +Services panel head is left as-is (no subtitle needed). + +### 3. Make LOGS / DOCS obvious +Restyle the per-service `.lnk` links to small bordered pill buttons that reuse +the look of the existing `.act` controls (1px border, rounded, accent on +hover) instead of underline-on-hover text. Keep them compact so the row height +is unchanged. Markup stays the same (``); only CSS changes, +plus an accessible `:focus-visible` ring to match `.act`. + +### 4. Fix top-heavy emptiness +Vertically center the page content when it is shorter than the viewport, while +falling back to normal top-aligned scrolling once there are enough services to +fill the screen. Implemented with a viewport-relative min-height plus flex +auto-margins (which center but never clip): + +- The dashboard is a Dash app: the layout is mounted inside Dash's own wrapper + divs, so a `body { flex }` chain would not reliably reach `.page`. Instead, + size `.page` directly against the viewport. +- `.page` gets `min-height:calc(100vh - 4rem)` (the `4rem` budgets for the top + banner), `box-sizing:border-box` (there is no global reset, and `.page` has + padding), and `display:flex; flex-direction:column`. `vh` is + viewport-relative regardless of ancestor heights, so this needs no changes to + body or the Dash wrappers. +- The inner `.grid` gets `margin-block:auto`. Auto margins center the grid + vertically when there is spare room; when the grid is taller than the + available space the margins collapse to zero and the page scrolls normally — + no overflow clipping. + +This removes the void below the cards on a tall screen without adding content. + +## Testing + +Extend the existing guard tests in `tests/manager_test/test_ui_redesign.py` +(asset/markup string asserts, consistent with the current approach): + +- Notifications subtitle text (`panel-sub`) is present in the rendered layout. +- `.panel-sub` style exists in `dashboard_styles.css`. +- `.lnk` is styled as a bordered button (border + border-radius) and has a + `:focus-visible` rule. +- `.grid` uses the new `2.6fr 0.9fr` template. +- Vertical-fill rule present (`.page` has `min-height:calc(100vh - 4rem)` and + `.grid` has `margin-block:auto`). + +Run locally before pushing: `black --check`, `flake8`, `pylint manager`, and +`pytest tests/manager_test/test_ui_redesign.py`. + +## Rollout + +Frontend-only; ships in the next `develop` → `master` release alongside the +logs-viewer work. The live server picks it up on the next manager image build +(connectors untouched). diff --git a/docs/superpowers/specs/2026-06-29-logs-viewer-enhancements-design.md b/docs/superpowers/specs/2026-06-29-logs-viewer-enhancements-design.md new file mode 100644 index 0000000..f474305 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-logs-viewer-enhancements-design.md @@ -0,0 +1,66 @@ +# Logs Viewer Enhancements + Responsive Layout — Design Spec + +**Date:** 2026-06-29 +**Branch:** `logs-viewer-enhancements` (off `develop`) +**Status:** Approved design, pending spec review → implementation plan +**Builds on:** the 1.4.0 UI redesign ([[daeploy-ui-redesign]]); same FastAPI + Dash + self-contained-assets stack. + +## Goal + +Make the redesigned UI use the screen better and turn the logs view into a real log console: + +- **A. Responsive logs view** — fills the viewport (height + width) instead of a fixed `60vh` / `1180px` card. +- **B. Responsive dashboard** — uses much more of the screen instead of a `1180px` centered column. +- **C. Export logs** — download a service's (or the manager's) full log as a file. +- **D. Search logs** — find text across the full log, filtered to matching lines. + +## Constraints + +- **Reuse the shared `manager/templates/logs.html`** — it already serves both per-service (`/services/~logs/view`) and manager (`/logs/view`) logs, so both get every feature here. +- **No backend changes.** The existing endpoints already support what's needed: `/services/~logs?...&tail=all&follow=false` and `/logs/stream?tail=all&follow=false` return the full log. Export and search are client-side fetches against these. +- **Self-contained** (no CDNs), consistent with the redesign tokens/theme. +- **Preserve the live tail** — streaming + the Follow/auto-scroll/jump behavior stay; the new features layer on top. + +## A. Responsive logs view + +- Drop `.page { max-width:1180px }` for the logs page → full width with modest side padding. +- Lay the page out as a **flex column filling the viewport**: top bar + `.logs-head` toolbar take natural height; `.console` gets `flex:1` (and `min-height:0`) so it grows/shrinks with the window and screen resolution. Remove the fixed `height:60vh`. +- Buffer cap stays for the *live tail* (keep DOM light); search/export bypass it by fetching the full log. + +## B. Responsive dashboard + +- In `manager/assets/dashboard_styles.css`, change `.page { max-width:1180px }` → `max-width:1600px` (still centered, generous side padding). The services/notifications `.grid` then uses far more width. (Decision: capped 1600px centered, not fully fluid — avoids absurd line lengths on ultrawide.) + +## C. Export logs to a file + +- An **Export** button in the logs toolbar (`.logs-tools`). +- On click: fetch the **full** log (`full_url`, i.e. the endpoint with `tail=all&follow=false`), build a `Blob`, and trigger a download. +- **Filename:** `‹title›_‹subtitle›_‹UTC-timestamp›.log` for services (e.g. `status_code_v0.1.0_20260629-153012.log`); `manager_‹UTC-timestamp›.log` for manager logs. Plain text, `.log`. +- Pure client-side (`fetch` → `Blob` → object URL → ``). Works on both views. + +## D. Search (filter to matches) + +- A **search input** in the toolbar, with a **match-count** label. +- On input (debounced ~250 ms) with a non-empty term: fetch the full log once (`full_url`), render **only lines containing the term** (case-insensitive substring), **highlight** the matched substring, and show the count (e.g. `37 matches` / `No matches`). +- **Follow auto-pauses** while a search is active (you're viewing history, not the tail). +- **Clearing** the box restores the **live tail** by clearing the console and restarting the live-tail stream (re-fetch `stream_url`, `tail=200&follow=true`), with Follow re-enabled. +- Highlighting uses DOM text nodes + a ``-style span (never `innerHTML` on log content — XSS-safe, consistent with the existing `textContent` rendering). +- No regex / no case toggle for now (YAGNI); case-insensitive substring only. + +## Toolbar layout + +`[🔍 search…] [37 matches] · [Export] · [● Live] [Follow ▢]` — search + count on the left of the tools group, Export next, then the existing Live indicator + Follow toggle. + +## Implementation outline (for the plan) + +1. **Routes** (`manager/routers/service_api.py::service_logs_view`, `manager/routers/logging_api.py::manager_logs_view`): pass one extra context value, **`full_url`** (the same endpoint as `stream_url` but `tail=all&follow=false`), e.g. service `/services/~logs?name=..&version=..&tail=all`; manager `/logs/stream?tail=all`. +2. **`manager/templates/logs.html`**: add the search input + match-count + Export button to `.logs-tools`; CSS for the full-bleed flex layout, the search box, and `` highlight; JS for export (fetch full → download), search (fetch full → filter/highlight/count, pause follow), and clear-search (resume tail). +3. **`manager/assets/dashboard_styles.css`**: `.page` max-width 1180 → 1600. +4. **Tests** (`tests/manager_test/test_ui_redesign.py`): assert `logs.html` has the search input, match-count, and Export elements; assert the view routes include `full_url` (tail=all); assert the logs `.page` is no longer capped at 1180 and the dashboard `.page` is 1600. +5. **Verify** live in the running manager: resize (console fills viewport), export downloads a `.log`, search filters with a count, clear resumes the tail; dashboard uses the wider layout. + +## Risks / notes + +- Fetching `tail=all` on a very large log transfers the whole thing for export/search — acceptable and expected per the "full history" decision; the live tail still uses `tail=200`. +- Search refetches the full log on each new term (debounced); fine for typical logs. Could cache the fetched full log per session as a later optimization (not now). +- This is a separate feature branch off `develop`; ships in a later release (e.g. 1.4.1 / 1.5.0), independent of the in-flight 1.4.0 PyPI publish. diff --git a/manager/app.py b/manager/app.py index f7236d9..759d4b9 100644 --- a/manager/app.py +++ b/manager/app.py @@ -2,8 +2,10 @@ import logging from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from starlette.responses import RedirectResponse from starlette.middleware.wsgi import WSGIMiddleware +from starlette.staticfiles import StaticFiles from manager.routers import ( admin_api, @@ -16,8 +18,7 @@ from manager import proxy from manager.database.database import initialize_db from manager.database import service_db -from manager.constants import get_manager_version - +from manager.constants import get_manager_version, cors_enabled, cors_config # Setup logger logging_api.setup_logging() @@ -30,6 +31,10 @@ version=get_manager_version(), ) +# CORS middleware +if cors_enabled(): + app.add_middleware(CORSMiddleware, **cors_config()) + # Services subapi app.include_router(service_api.ROUTER, prefix="/services", tags=["Service"]) @@ -38,6 +43,9 @@ notification_api.ROUTER, prefix="/notifications", tags=["Notification"] ) +# Static assets +app.mount("/assets", StaticFiles(directory="manager/assets"), name="assets") + # Dashboard subapi app.mount("/dashboard", WSGIMiddleware(dashboard_api.app.server)) diff --git a/manager/assets/daeploy_mark.svg b/manager/assets/daeploy_mark.svg new file mode 100644 index 0000000..285b8e4 --- /dev/null +++ b/manager/assets/daeploy_mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/manager/assets/dashboard_styles.css b/manager/assets/dashboard_styles.css index 928f3b6..3dc20a5 100644 --- a/manager/assets/dashboard_styles.css +++ b/manager/assets/dashboard_styles.css @@ -1,238 +1,150 @@ -/* Table of contents -–––––––––––––––––––––––––––––––––––––––––––––––––– -- Banner -- Modal -- Tabs -- Main Dashboard Tab -- Measurement Tab -- Tables/Dropdown -- Containers -- Media Queries -–––––––––––––––––––––––––––––––––––––––––––––––––– */ - - -body { - background-color: #1e2130; - color: #f3f5f4; - font-family: "Open Sans", sans-serif; - width: 100%; - height: 100vh; - max-width: 100% !important; - overflow-x: hidden; - margin: 0; -} - -.banner { - height: fit-content; - background-color: #1e2130; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid #4B5460; - padding: 1rem 10rem; - width: 100%; -} - -.banner h5 { - font-family: 'Open Sans Semi Bold', sans-serif; - font-weight: 500; - line-height: 1.2; - font-size: 2rem; - letter-spacing: 0.1rem; - text-transform: uppercase; -} - -.banner h6 { - font-size: 1.6rem; - line-height: 1; -} - -#banner-logo { - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-end; -} - -.banner Img { - height: 3rem; - margin: 0px 10px; -} - -/* container */ -#big-app-container { - max-width: 100%; - display: flex; - flex-direction: column; - align-items: center; - padding: 0 6rem; -} - -#app-container { - background: #161a28 !important; - margin: 1rem 2rem !important; - max-width: 100% !important; - width: 100% !important; - height: calc(100vh - 10rem - 1px) !important; -} - -#app-container * { - box-sizing: border-box; - -moz-box-sizing: border-box; -} -/* Tabs -–––––––––––––––––––––––––––––––––––––––––––––––––– */ - -.daeploy_custom-tabs { - background-color: #161a28 !important; - text-transform: uppercase !important; - font-weight: 500 !important; - font-size: 16px !important; - height: fit-content !important; - cursor: pointer !important; - color: #f8f9fc !important; - padding-top: 10px !important; - padding-bottom: 10px !important; - border-top: #161a28 solid !important; - border-left: #161a28 solid !important; - border-right: #161a28 solid !important; -} - -.daeploy_custom-tab{ - border: #161a28 solid !important; -} - -.daeploy_custom-tab--selected{ - border-bottom: #91dfd2 solid !important; - border-top: #161a28 solid !important; - border-left: #161a28 solid !important; - border-right: #161a28 solid !important; -} - -/* Table */ -table { - margin-right: 3px; - margin-left: 3px; - border-collapse: collapse; - width: 100%; -} - -td, th { - text-align: left; - padding: 8px; +/* Daeploy dashboard styles — uses design tokens from tokens.css */ + +body{background:var(--ground);color:var(--text);font-family:var(--sans);margin:0;overflow-x:hidden;} +a{color:inherit;text-decoration:none;} + +/* ---------- top bar ---------- */ +.top{ + display:flex;align-items:center;justify-content:space-between;gap:1rem; + padding:1.05rem 1.6rem;border-bottom:1px solid var(--line-soft); + background:linear-gradient(180deg,rgba(22,28,44,.6),transparent); +} +.top .left{display:flex;align-items:center;gap:1.1rem} +.vchip{ + font-family:var(--mono);font-size:11px;letter-spacing:.08em;color:var(--muted); + border:1px solid var(--line);border-radius:999px;padding:.2rem .6rem; +} +.vchip b{color:var(--accent);font-weight:600} + +/* ---------- actions nav ---------- */ +.actions{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap} +.act{ + font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase; + color:var(--muted);background:transparent;border:1px solid var(--line); + border-radius:8px;padding:.42rem .7rem;cursor:pointer;text-decoration:none; + transition:color .15s,border-color .15s,background .15s;display:inline-block; +} +.act:hover{color:var(--text);border-color:var(--accent-dim)} +.act:focus-visible{outline:2px solid var(--accent);outline-offset:2px;} +.act.danger:hover{color:var(--crit);border-color:var(--crit)} + +/* ---------- page / grid ---------- */ +.page{ + max-width:1600px;margin:0 auto;padding:1.8rem 1.6rem 4rem; + box-sizing:border-box;min-height:calc(100vh - 4rem); + display:flex;flex-direction:column; +} +.grid{ + display:grid;grid-template-columns:2.6fr 0.9fr;gap:1.4rem;align-items:start; + width:100%; + /* Bias content toward the top with a small, capped gap below the banner — + the remaining viewport slack falls below, so a few short rows no longer + float to mid-screen with a large empty band above. */ + margin-top:clamp(0.5rem, 5vh, 2.5rem); +} + +/* ---------- panels ---------- */ +.panel{ + background:var(--surface);border:1px solid var(--line-soft);border-radius:16px; + overflow:hidden; +} +.panel-head{ + display:flex;align-items:center;justify-content:space-between; + padding:1rem 1.2rem .85rem;border-bottom:1px solid var(--line-soft); +} +.panel-head h2{font-size:.82rem;letter-spacing:.04em;font-weight:600;margin:0} +.panel-sub{margin:.25rem 0 0;font-size:11px;line-height:1.4;color:var(--muted)} +.count{font-family:var(--mono);font-size:11px;color:var(--faint)} + +/* ---------- service rows ---------- */ +.svc{ + display:grid; + grid-template-columns:1.3fr .7fr auto; + grid-template-areas:"id state actions"; + align-items:center;gap:.8rem; + padding:.5rem 1.2rem;border-bottom:1px solid var(--line-soft); + transition:background .15s; +} +.svc:last-child{border-bottom:none} +.svc:hover{background:var(--surface-2)} +.svc .id{grid-area:id;display:flex;align-items:center;gap:.7rem;min-width:0} +.svc .name{font-weight:560;font-size:.96rem;letter-spacing:-.01em} +.svc .ver{font-family:var(--mono);font-size:11px;color:var(--muted);margin-top:.12rem} + +/* ---------- pin badges ---------- */ +.pin{ + flex:none;width:26px;height:26px;border-radius:8px;display:grid;place-items:center; + font-size:11px;font-family:var(--mono); + border:1px solid var(--line);color:var(--faint); +} +.pin.main{color:var(--accent);border-color:var(--accent-dim);background:rgba(94,230,208,.06)} + +/* ---------- state column ---------- */ +.state{grid-area:state;display:flex;align-items:center;gap:.55rem;min-width:0} +.sdot{width:8px;height:8px;border-radius:50%;flex:none} +.sdot.run{background:var(--ok);box-shadow:0 0 0 3px rgba(61,220,151,.15)} +.sdot.run.live{animation:pulse 2.4s ease-in-out infinite} +.sdot.shadow{background:var(--accent);box-shadow:0 0 0 3px rgba(94,230,208,.15)} +.sdot.stop{background:var(--faint)} +.state .lbl{font-size:.82rem} +.lbl.stopped{color:var(--muted)} +.state .since{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-top:.1rem} + +/* ---------- badges ---------- */ +.badge{ + font-family:var(--mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase; + padding:.16rem .42rem;border-radius:5px; +} +.badge.shadow{color:var(--accent);background:rgba(94,230,208,.1)} + +/* ---------- service actions ---------- */ +.svc-actions{grid-area:actions;display:flex;gap:.4rem} +.lnk{ + font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase; + color:var(--muted);text-decoration:none; + border:1px solid var(--line);border-radius:7px;padding:.34rem .6rem; + transition:color .15s,border-color .15s,background .15s; +} +.lnk:hover{color:var(--accent);border-color:var(--accent-dim);background:rgba(94,230,208,.06)} +.lnk:focus-visible{outline:2px solid var(--accent);outline-offset:2px} + +/* ---------- notifications ---------- */ +.note{ + display:grid;grid-template-columns:auto 1fr;gap:.75rem; + padding:.85rem 1.2rem;border-bottom:1px solid var(--line-soft); +} +.note:last-child{border-bottom:none} +.sev{width:3px;border-radius:3px;align-self:stretch} +.sev.info{background:var(--accent)} +.sev.warn{background:var(--warn)} +.sev.crit{background:var(--crit)} +.note .msg{font-size:.86rem;line-height:1.45} +.note .meta{ + font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase; + color:var(--faint);margin-top:.3rem;display:flex;gap:.6rem; +} +.sev-tag{font-weight:600} +.sev-tag.info{color:var(--accent)} +.sev-tag.warn{color:var(--warn)} +.sev-tag.crit{color:var(--crit)} + +.empty{padding:2.2rem 1.2rem;text-align:center;color:var(--faint);font-size:.85rem} + +/* ---------- wordmark ---------- */ +.mark{display:inline-flex;align-items:center;gap:.6rem} +.wordmark{font-weight:600;font-size:1.4rem;letter-spacing:-.02em} +.wordmark b{color:var(--accent);font-weight:600} + +/* ---------- keyframes ---------- */ +@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(61,220,151,.45)}50%{box-shadow:0 0 0 6px rgba(61,220,151,0)}} + +/* ---------- responsive ---------- */ +@media (max-width:760px){ + .grid{grid-template-columns:1fr} + .svc{grid-template-columns:1fr;grid-template-areas:"id" "state" "actions";gap:.55rem} + .svc-actions{justify-content:flex-start} + .top{flex-direction:column;align-items:flex-start;gap:.9rem} +} +@media (prefers-reduced-motion:reduce){ + .sdot.run.live{animation:none} } - -tr:nth-child(even) { - background-color: #343744; -} - -/* Links */ -a:visited { text-decoration: none; color:white; } -a:hover { text-decoration: none; color:green; } -a:focus { text-decoration: none; color:yellow; } -a:hover, a:active { text-decoration: none; color:green } - -/* Severity */ -.severity-info { - background-color: #99cc33; - color:black; - font-weight: bold; - text-align: center; - width: 1px; -} -.severity-warning { - background-color: #ffcc00; - color:black; - font-weight: bold; - text-align: center; - width: 1px; -} -.severity-critical { - background-color: #cc3300; - color:black; - font-weight: bold; - text-align: center; - width: 1px; -} - -/* Green Text */ -.green-text { - color: lightgreen; -} - -/* User information */ -.user-actions { - position:absolute; - right:10px; - top:5px; - display: block; - text-align: center; -} - -.logout { - cursor: pointer; - color: white; - text-decoration: none; - border: 2px solid white; - border-radius: 30px; - transition-duration: .2s; - -webkit-transition-duration: .2s; - -moz-transition-duration: .2s; - background-color: #161a28; - padding: 4px 20px; - font-size: 0.7rem; -} - -.logout:hover { - background-color:red; - color:white; -} - -.version-identifier { - text-align: center; - font-size: 1.1rem; -} - -/* - ##Device = Most of the Smartphones Mobiles / ipad (Portrait) - */ -@media only screen and (max-width: 950px) { - - body { - font-size: 1.3rem; - } - - #big-app-container { - padding: 1rem; - } - - .banner { - flex-direction: column-reverse; - padding: 1rem 0.5rem; - } - - #banner-text { - text-align: center; - } - - .banner h5 { - font-size: 1.4rem; - } - - .banner h6 { - font-size: 1.3rem; - } - - #banner-logo button { - display: none; - } - - .banner Img { - height: 3rem; - margin: 1rem; - } - - #app-container { - height: auto; - } \ No newline at end of file diff --git a/manager/assets/fonts/inter-400.woff2 b/manager/assets/fonts/inter-400.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/manager/assets/fonts/inter-400.woff2 differ diff --git a/manager/assets/fonts/inter-500.woff2 b/manager/assets/fonts/inter-500.woff2 new file mode 100644 index 0000000..54f0a59 Binary files /dev/null and b/manager/assets/fonts/inter-500.woff2 differ diff --git a/manager/assets/fonts/inter-600.woff2 b/manager/assets/fonts/inter-600.woff2 new file mode 100644 index 0000000..d189794 Binary files /dev/null and b/manager/assets/fonts/inter-600.woff2 differ diff --git a/manager/assets/fonts/jbmono-400.woff2 b/manager/assets/fonts/jbmono-400.woff2 new file mode 100644 index 0000000..5858873 Binary files /dev/null and b/manager/assets/fonts/jbmono-400.woff2 differ diff --git a/manager/assets/fonts/jbmono-500.woff2 b/manager/assets/fonts/jbmono-500.woff2 new file mode 100644 index 0000000..be878e6 Binary files /dev/null and b/manager/assets/fonts/jbmono-500.woff2 differ diff --git a/manager/assets/tokens.css b/manager/assets/tokens.css new file mode 100644 index 0000000..37e31ad --- /dev/null +++ b/manager/assets/tokens.css @@ -0,0 +1,17 @@ +/* Daeploy design tokens — single source of truth for color + type. */ +@font-face{font-family:"Inter";font-weight:400;font-display:swap;src:url(fonts/inter-400.woff2) format("woff2");} +@font-face{font-family:"Inter";font-weight:500;font-display:swap;src:url(fonts/inter-500.woff2) format("woff2");} +@font-face{font-family:"Inter";font-weight:600;font-display:swap;src:url(fonts/inter-600.woff2) format("woff2");} +@font-face{font-family:"JetBrains Mono";font-weight:400;font-display:swap;src:url(fonts/jbmono-400.woff2) format("woff2");} +@font-face{font-family:"JetBrains Mono";font-weight:500;font-display:swap;src:url(fonts/jbmono-500.woff2) format("woff2");} + +:root{ + --ground:#0E1320; --surface:#161C2C; --surface-2:#1C2438; + --line:#28324A; --line-soft:#1E2638; + --text:#E7ECF5; --muted:#8B95AC; --faint:#5C6680; + --accent:#5EE6D0; --accent-dim:#2E5A56; --accent-ink:#072019; + --ok:#3DDC97; --warn:#F4B740; --crit:#F2585B; + --shadow:0 24px 60px -24px rgba(0,0,0,.7); + --sans:"Inter","Segoe UI",system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; + --mono:"JetBrains Mono",ui-monospace,"SF Mono",Menlo,Consolas,monospace; +} diff --git a/manager/constants.py b/manager/constants.py index 1619800..6428d14 100644 --- a/manager/constants.py +++ b/manager/constants.py @@ -1,6 +1,7 @@ """ Constants and config """ + import os from pathlib import Path @@ -107,6 +108,29 @@ def configuration_email(): return os.environ.get("DAEPLOY_CONFIG_EMAIL") +def cors_enabled(): + return os.environ.get("DAEPLOY_ENABLE_CORS") + + +def cors_allowed_origins(): + """assumes allowed origin are passed as a single string separated by ; + Example 'https://origin1.com;https://orogin2.com' + + Returns: + list: url of allowed origins + """ + return os.environ.get("DAEPLOY_ALLOW_ORIGIN", "").split(";") + + +def cors_config(): + config = {} + config["allow_credentials"] = False + config["allow_origins"] = cors_allowed_origins() + config["allow_methods"] = ["GET", "POST", "PUT", "DELETE"] + config["allow_headers"] = ["Authorization"] + return config + + def notification_email_config(): sender_email = configuration_email() sender_pass = os.environ.get("DAEPLOY_CONFIG_EMAIL_PASSWORD") diff --git a/manager/data_models/request_models.py b/manager/data_models/request_models.py index 10e3f13..6b38a91 100644 --- a/manager/data_models/request_models.py +++ b/manager/data_models/request_models.py @@ -3,8 +3,8 @@ import semver from pydantic.types import SecretStr -from pydantic import BaseModel, validator, HttpUrl -from fastapi import Path, UploadFile +from pydantic import BaseModel, field_validator, HttpUrl, ConfigDict +from fastapi import UploadFile, Query from manager.constants import ( DAEPLOY_DEFAULT_INTERNAL_PORT, @@ -16,8 +16,8 @@ class BaseService(BaseModel): name: str version: str - # pylint: disable=no-self-use - @validator("name") + @field_validator("name") + @classmethod def must_adhere_to_docker_requirements(cls, name): # Only allow a name to contain lower case letters, numbers and underscore # anywhere but in the beginning and end @@ -29,16 +29,16 @@ def must_adhere_to_docker_requirements(cls, name): ) return name - # pylint: disable=no-self-use - @validator("version") + @field_validator("version") + @classmethod def must_be_semver_string(cls, version): - if not semver.VersionInfo.isvalid(version): + if not semver.Version.is_valid(version): raise ValueError("Version must be a semantic version string.") return version class BaseNewServiceRequest(BaseService): - port: int = Path(default=DAEPLOY_DEFAULT_INTERNAL_PORT, gt=0) + port: int = Query(default=DAEPLOY_DEFAULT_INTERNAL_PORT, gt=0) run_args: Dict = {} @@ -49,8 +49,8 @@ class BaseNewS2IServiceRequest(BaseNewServiceRequest): class ServiceImageRequest(BaseNewServiceRequest): image: str - class Config: - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "name": "myservice", "version": "0.0.1", @@ -59,13 +59,14 @@ class Config: "run_args": {}, } } + ) class ServiceGitRequest(BaseNewS2IServiceRequest): git_url: HttpUrl - class Config: - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "name": "myservice", "version": "0.0.1", @@ -74,13 +75,14 @@ class Config: "run_args": {}, } } + ) class ServiceTarRequest(BaseNewS2IServiceRequest): file: UploadFile - class Config: - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "name": "myservice", "version": "0.0.1", @@ -89,13 +91,14 @@ class Config: "run_args": {}, } } + ) class ServicePickleRequest(ServiceTarRequest): requirements: List[str] - class Config: - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "name": "myservice", "version": "0.0.1", @@ -104,6 +107,7 @@ class Config: "requirements": [], } } + ) class NotificationRequest(BaseModel): @@ -112,7 +116,7 @@ class NotificationRequest(BaseModel): msg: str severity: int dashboard: bool - emails: Union[List[str], None] + emails: Union[List[str], None] = None timer: int timestamp: str diff --git a/manager/data_models/response_models.py b/manager/data_models/response_models.py index 58c710a..1e238f7 100644 --- a/manager/data_models/response_models.py +++ b/manager/data_models/response_models.py @@ -34,28 +34,32 @@ class StateResponse(BaseModel): Error: str StartedAt: str FinishedAt: str - Health: Optional[HealthResponse] + Health: Optional[HealthResponse] = None class NetworkSettingsResponse(BaseModel): - Bridge: str + # Docker Engine 29+ no longer populates the legacy top-level network + # fields (Bridge, IPAddress, MacAddress, etc.) for containers attached + # only to a custom network; that data now lives under `Networks`. Keep + # them optional so inspection doesn't fail response validation. SandboxID: str - HairpinMode: bool - LinkLocalIPv6Address: str - LinkLocalIPv6PrefixLen: int Ports: dict SandboxKey: str - SecondaryIPAddresses: Optional[str] - SecondaryIPv6Addresses: Optional[str] - EndpointID: str - Gateway: str - GlobalIPv6Address: str - GlobalIPv6PrefixLen: int - IPAddress: str - IPPrefixLen: int - IPv6Gateway: str - MacAddress: str Networks: Dict[str, Dict] + Bridge: Optional[str] = None + HairpinMode: Optional[bool] = None + LinkLocalIPv6Address: Optional[str] = None + LinkLocalIPv6PrefixLen: Optional[int] = None + SecondaryIPAddresses: Optional[str] = None + SecondaryIPv6Addresses: Optional[str] = None + EndpointID: Optional[str] = None + Gateway: Optional[str] = None + GlobalIPv6Address: Optional[str] = None + GlobalIPv6PrefixLen: Optional[int] = None + IPAddress: Optional[str] = None + IPPrefixLen: Optional[int] = None + IPv6Gateway: Optional[str] = None + MacAddress: Optional[str] = None class InspectResponse(BaseModel): @@ -76,9 +80,14 @@ class InspectResponse(BaseModel): MountLabel: str ProcessLabel: str AppArmorProfile: str - ExecIDs: Optional[List[str]] + ExecIDs: Optional[List[str]] = None HostConfig: dict - GraphDriver: dict + # Docker 29+ with the containerd/overlayfs image store returns `Storage` + # (and `ImageManifestDescriptor`) instead of the legacy `GraphDriver`, so + # none of these can be required for inspection to work across drivers. + GraphDriver: Optional[dict] = None + Storage: Optional[dict] = None + ImageManifestDescriptor: Optional[dict] = None Mounts: list Configuration: dict = Field(..., alias="Config") NetworkSettings: NetworkSettingsResponse diff --git a/manager/database/auth_db.py b/manager/database/auth_db.py index 2865b94..0d73595 100644 --- a/manager/database/auth_db.py +++ b/manager/database/auth_db.py @@ -19,7 +19,8 @@ def add_user_record(username: str, password: str): """ with session_scope() as session: new_user = User( - name=username, password=bcrypt.hashpw(password.encode(), bcrypt.gensalt()) + name=username, + password=bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode(), ) session.add(new_user) diff --git a/manager/database/database.py b/manager/database/database.py index be8a6d4..ae2bc73 100644 --- a/manager/database/database.py +++ b/manager/database/database.py @@ -4,8 +4,7 @@ import logging from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey from manager.constants import DAEPLOY_DATA_DIR, get_admin_password @@ -128,9 +127,8 @@ def initialize_db(): def remove_db(): """Removes db""" + engine.dispose() try: MANAGER_DB_PATH.unlink() except FileNotFoundError: - # Path.unlink(missing_ok=True) gives same behavior but was - # not introduced until python 3.8 pass diff --git a/manager/proxy.py b/manager/proxy.py index d44bc89..a035354 100644 --- a/manager/proxy.py +++ b/manager/proxy.py @@ -274,6 +274,10 @@ def get_manager_configuration() -> dict: """ rule = f"""Host(`{get_proxy_domain_name()}`)""" rule_login = f"""Host(`{get_proxy_domain_name()}`) && PathPrefix(`/auth/login`)""" + # Static UI assets (CSS, fonts, logo) must load on the login page *before* + # the user authenticates, so they are served by a router without the auth + # middleware. These are non-sensitive presentation files only. + rule_assets = f"""Host(`{get_proxy_domain_name()}`) && PathPrefix(`/assets`)""" config = { "http": { @@ -290,6 +294,12 @@ def get_manager_configuration() -> dict: middlewares=None, tls=https_proxy(), ), + "static_assets": get_router_configuration( + rule=rule_assets, + service="manager_service", + middlewares=None, + tls=https_proxy(), + ), }, "services": { "manager_service": { diff --git a/manager/routers/auth_api.py b/manager/routers/auth_api.py index a528335..7b0e268 100644 --- a/manager/routers/auth_api.py +++ b/manager/routers/auth_api.py @@ -115,8 +115,9 @@ def show_login_page(request: Request, destination: Optional[str] = "/"): # noqa: DAR101,DAR201,DAR401 """ return TEMPLATES.TemplateResponse( - "login.html", - {"request": request, "ACTION": f"/auth/login?destination={destination}"}, + request=request, + name="login.html", + context={"ACTION": f"/auth/login?destination={destination}"}, status_code=401, ) @@ -143,7 +144,12 @@ def login_user( LOGGER.exception(f"User {username} failed to login!") return RedirectResponse(url=destination, status_code=303) - if not bcrypt.checkpw(password.get_secret_value().encode(), record.password): + stored_pw = ( + record.password.encode() + if isinstance(record.password, str) + else record.password + ) + if not bcrypt.checkpw(password.get_secret_value().encode(), stored_pw): return RedirectResponse(url=destination, status_code=303) # Construct token diff --git a/manager/routers/dashboard_api.py b/manager/routers/dashboard_api.py index 30eb2af..8897545 100644 --- a/manager/routers/dashboard_api.py +++ b/manager/routers/dashboard_api.py @@ -2,9 +2,7 @@ from datetime import datetime import dash -import dash_core_components as dcc -import dash_html_components as html -from dash.dependencies import Input, Output +from dash import dcc, html, Input, Output from manager.routers.service_api import read_services, inspect_service from manager.routers.notification_api import get_notifications, delete_notifications @@ -23,88 +21,68 @@ def build_user_section(): - return html.Div( - id="user-actions", - className="user-actions", + return html.Nav( + className="actions", children=[ - html.P(f"v:{get_manager_version()}", className="version-identifier"), - html.P(), html.A( - "LOGS", - id="manager-logs-buttom", - href=f"{get_external_proxy_url()}/logs", - className="logout", + "Logs", + href=f"{get_external_proxy_url()}/logs/view", + className="act", ), html.A( - "API DOCS", - id="documenation-button", + "API Docs", href=f"{get_external_proxy_url()}/docs", - className="logout", + className="act", ), - html.P(), html.Button( - "CLEAR NOTIFICATIONS", + "Clear notifications", id="clear-notifications-button", n_clicks=0, - className="logout", + className="act", ), html.A( - "LOGOUT", - id="logout-button", + "Log out", href=f"{get_external_proxy_url()}/auth/logout", - className="logout", + className="act danger", ), ], ) def build_banner(): - return html.Div( - id="banner", - className="banner", + return html.Header( + className="top", children=[ html.Div( - id="banner-text", + className="left", children=[ - html.Img(src=app.get_asset_url("daeploy_white_icon.png")), - dcc.Markdown( - """ - ### Daeploy Dashboard - by Viking Analytics AB - """ + html.Div( + className="mark", + children=[ + html.Img( + src=app.get_asset_url("daeploy_mark.svg"), + width=26, + height=26, + ), + html.Span( + children=[ + "dae", + html.B("ploy"), + ], + className="wordmark", + ), + ], ), - ], - ), - ], - ) - - -def build_tabs(): - return html.Div( - id="daeploy_tabs", - children=[ - dcc.Tabs( - id="app-tabs", - className="daeploy_custom-tabs", - # Set tab-1 to active from start. - value="tab1", - children=[ - dcc.Tab( - id="services", - label="Services", - value="tab1", - className="daeploy_custom-tabs", - selected_className="daeploy_custom-tab--selected", - ), - dcc.Tab( - id="notification", - label="Notifications", - value="tab2", - className="daeploy_custom-tabs", - selected_className="daeploy_custom-tab--selected", + html.Span( + children=[ + "manager ", + html.B(f"v: {get_manager_version()}"), + ], + className="vchip", ), ], - ) + ), + build_user_section(), ], ) @@ -142,72 +120,90 @@ def update_content(tab_switch, interval, n_clicks): ) -def generate_table_services(): - """Generates a HTML table with the service information +@app.callback( + Output("notifications-content", "children"), + Input("interval1", "n_intervals"), + Input("clear-notifications-button", "n_clicks"), +) +# pylint: disable=unused-argument +def update_notifications(interval, n_clicks): + return generate_table_notifications() - Returns: - html.Table: The html table with service information - """ - services = read_services() - headers = ["Main", "Service name", "Version", "State", "Logs", "Documentation"] - return html.Table( - # Header - [html.Tr([html.Th(col) for col in headers])] - + - # Body - [ - html.Tr( - # Main/Shadow - [ - html.Td("*", className="green-text") - if service["main"] - else html.Td("") - ] - + - # Name - [html.Td(get_service_link(service))] - # Version - + [html.Td(service["version"])] - # Service state - + [html.Td(get_service_state(service))] - # Log link - + [html.Td(get_service_log_link(service))] - # Docs link - + [html.Td(get_service_docs_link(service))] - ) - for service in services - ] - ) +def _service_since(inspection, running): + """Format the running/stopped 'since' timestamp from a service inspection.""" + key = "StartedAt" if running else "FinishedAt" + raw = inspection["State"][key].split(".")[0] + parsed = datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S") + return parsed.strftime("%Y-%m-%d %H:%M:%S") -def get_service_state(service): - """Getter for the state of the service. - The statue of the service is collected from the inspect information. +def build_service_row(service): + """Build a single styled service row Div for the dashboard.""" + is_main = service["main"] + inspection = inspect_service(service["name"], service["version"]) + running = inspection["State"]["Running"] - Args: - service (dict): The service to get the state from. + if running and is_main: + dot_class, state_label = "sdot run live", "Running" + elif running: + dot_class, state_label = "sdot shadow", "Mirroring traffic" + else: + dot_class, state_label = "sdot stop", "Stopped" - Returns: - str: The state of the 'service' - """ - inspection = inspect_service(service["name"], service["version"]) + if is_main: + pin = html.Span("★", className="pin main", title="Main version") + elif not running: + pin = html.Span("○", className="pin", title="Stopped") + else: + pin = html.Span("↗", className="pin", title="Shadow version") - running = inspection["State"]["Running"] - if running: - timestamp = inspection["State"]["StartedAt"] - running_msg = "Running" + if not is_main and running: + name = html.Div( + [service["name"], html.Span("shadow", className="badge shadow")], + className="name", + ) else: - timestamp = inspection["State"]["FinishedAt"] - running_msg = "Stopped" + name = html.Div(service["name"], className="name") - timestamp = datetime.strptime(timestamp.split(".")[0], "%Y-%m-%dT%H:%M:%S") - timestamp = timestamp.strftime("%Y-%m-%d %H:%M:%S") - return f" {running_msg} (since {timestamp})" + id_div = html.Div( + [pin, html.Div([name, html.Div(service["version"], className="ver")])], + className="id", + ) + state_div = html.Div( + [ + html.Span(className=dot_class), + html.Div( + [ + html.Div( + state_label, className="lbl" if running else "lbl stopped" + ), + html.Div( + f"since {_service_since(inspection, running)}", + className="since", + ), + ] + ), + ], + className="state", + ) + actions_div = html.Div( + [get_service_log_link(service), get_service_docs_link(service)], + className="svc-actions", + ) + return html.Div([id_div, state_div, actions_div], className="svc") -def get_link_style(): - return {"color": "white"} +def generate_table_services(): + """Generates service rows with the service information. + + Returns: + html.Div: A Div containing styled service rows. + """ + services = read_services() + if not services: + return html.Div("No services deployed.", className="empty") + return html.Div([build_service_row(service) for service in services]) def get_service_docs_link(service): @@ -223,7 +219,7 @@ def get_service_docs_link(service): return html.A( "Docs", href=(f"{proxy_url}/services/{service['name']}_{service['version']}/docs"), - style=get_link_style(), + className="lnk", ) @@ -236,94 +232,132 @@ def get_service_log_link(service): Returns: html.A: Html.A object with a href to the logs for 'service' """ - logs_end_point = f"{get_external_proxy_url()}/services/~logs" + proxy_url = get_external_proxy_url() return html.A( "Logs", - href=f"{logs_end_point}?name={service['name']}&version={service['version']}" - f"&follow=true&tail={DEFAULT_NUMBER_OF_LOGS}", - style=get_link_style(), - ) - - -def get_service_link(service): - service_endpoint = ( - f"{get_external_proxy_url()}/services/{service['name']}_{service['version']}/" + href=f"{proxy_url}/services/~logs/view" + f"?name={service['name']}&version={service['version']}", + className="lnk", ) - return html.A(service["name"], href=service_endpoint, style=get_link_style()) def generate_table_notifications(): - """Generates a HTML table with the notifications + """Generates notification rows. Returns: - html.Table: The html table with notification information + html.Div: A Div containing styled notification rows. """ notifications = get_notifications() - headers = [ - "Latest Timestamp", - "Service name", - "Version", - "Message", - "Count", - "Severity", - ] - dict_keys = ["timestamp", "service_name", "service_version", "msg", "counter"] - severity_colors = get_severity_colors(notifications) - return html.Table( - # Header - [html.Tr([html.Th(col) for col in headers])] - + - # Body - [ - html.Tr( - [html.Td(notifications[index[0]][key]) for key in dict_keys] - + [severity_colors[index[0]]] - ) - for index in reversed( - sorted(notifications.items(), key=lambda item: item[1]["timestamp"]) - ) - ] - ) + if not notifications: + return html.Div("No notifications.", className="empty") + + severity_map = get_severity_colors(notifications) + rows = [] + for index, _ in reversed( + sorted(notifications.items(), key=lambda item: item[1]["timestamp"]) + ): + notification = notifications[index] + sev_class, sev_label = severity_map[index] + timestamp = notification["timestamp"] + msg = notification["msg"] + + row = html.Div( + [ + html.Span(className=f"sev {sev_class}"), + html.Div( + [ + html.Div(msg, className="msg"), + html.Div( + [ + html.Span(sev_label, className=f"sev-tag {sev_class}"), + html.Span(timestamp), + ], + className="meta", + ), + ] + ), + ], + className="note", + ) + rows.append(row) + + return html.Div(rows) def get_severity_colors(notifications): - """Get the correct color for a severity. + """Get the CSS class and label for each notification's severity. Args: notifications (dict): The notifications. Returns: dict: Dict with the notification hash as the key and - a html.Td object with correct color class for the severity - as value. + a (css_class, label) tuple as the value. + Severity mapping: 0=Info, 1=Warning, 2=Critical. """ - tds = {} + result = {} for index, notification in notifications.items(): - color_class = "severity-info" - text = "Info" - if notification["severity"] == 1: - color_class = "severity-warning" - text = "Warning" - elif notification["severity"] == 2: - color_class = "severity-critical" - text = "Critical" - tds[index] = html.Td(text, className=color_class) - return tds + sev = notification["severity"] + if sev == 1: + result[index] = ("warn", "Warning") + elif sev == 2: + result[index] = ("crit", "Critical") + else: + result[index] = ("info", "Info") + return result app.layout = html.Div( - id="big-app-container", children=[ - # reload intevarl dcc.Interval(id="interval1", interval=5 * 1000, n_intervals=0), - build_user_section(), + # Hidden tabs component kept so update_content callback still resolves + dcc.Tabs(id="app-tabs", value="tab1", style={"display": "none"}), build_banner(), html.Div( - id="app-container", + className="page", children=[ - build_tabs(), - # Main app - html.Div(id="app-content"), + html.Div( + className="grid", + children=[ + # Services panel (hero) + html.Section( + className="panel", + children=[ + html.Div( + className="panel-head", + children=[html.H2("Services")], + ), + html.Div(id="app-content"), + ], + ), + # Notifications panel + html.Section( + className="panel", + children=[ + html.Div( + className="panel-head", + children=[ + html.Div( + [ + html.H2("Notifications"), + html.P( + "Alerts services raise via the SDK" + " notify() — info, warnings," + " critical.", + className="panel-sub", + ), + ] + ) + ], + ), + html.Div( + id="notifications-content", + children=[generate_table_notifications()], + ), + ], + ), + ], + ), ], ), ], diff --git a/manager/routers/logging_api.py b/manager/routers/logging_api.py index 6159ed6..5cb60d7 100644 --- a/manager/routers/logging_api.py +++ b/manager/routers/logging_api.py @@ -1,14 +1,17 @@ from datetime import datetime, timedelta import logging import sys -from typing import Union -from fastapi import APIRouter, HTTPException -from fastapi.responses import PlainTextResponse -from manager.constants import log_level, access_logs_enabled +from typing import Optional, Union +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import PlainTextResponse, StreamingResponse, HTMLResponse +from fastapi.templating import Jinja2Templates +from manager.constants import log_level, access_logs_enabled, get_manager_version from manager.runtime_connectors import RTE_CONN ROUTER = APIRouter() +TEMPLATES = Jinja2Templates(directory="manager/templates") + @ROUTER.get("/", response_class=PlainTextResponse) async def manager_logs(since: Union[datetime, None] = None) -> str: @@ -34,6 +37,45 @@ async def manager_logs(since: Union[datetime, None] = None) -> str: return logs +@ROUTER.get("/stream", response_class=StreamingResponse) +async def manager_logs_stream( + tail: Optional[int] = None, + follow: Optional[bool] = False, + since: Union[datetime, None] = None, +) -> str: + """Stream the manager logs (optionally following new output). + + \f + # noqa: DAR101,DAR201 + """ + return StreamingResponse( + RTE_CONN.manager_logs_stream(tail, follow, since), + media_type="text/plain", + headers={"X-Content-Type-Options": "nosniff"}, + ) + + +@ROUTER.get("/view", response_class=HTMLResponse) +def manager_logs_view(request: Request): + """HTML view that streams the manager logs with a follow/auto-scroll toggle. + + \f + # noqa: DAR101,DAR201 + """ + return TEMPLATES.TemplateResponse( + request=request, + name="logs.html", + context={ + "title": "manager", + "subtitle": f"v: {get_manager_version()}", + "stream_url": "/logs/stream?follow=true&tail=400", + "full_url": "/logs/stream", + "export_basename": "manager", + "manager_version": get_manager_version(), + }, + ) + + def setup_logging(): root_logger = logging.getLogger() root_logger.setLevel(log_level()) diff --git a/manager/routers/service_api.py b/manager/routers/service_api.py index 35a059e..5d6ec42 100644 --- a/manager/routers/service_api.py +++ b/manager/routers/service_api.py @@ -5,11 +5,13 @@ from datetime import datetime from typing import List, Optional from pathlib import Path +from urllib.parse import quote from pydantic import ValidationError, Json from cookiecutter.main import cookiecutter -from fastapi import APIRouter, HTTPException, File, UploadFile, Form -from fastapi.responses import StreamingResponse +from fastapi import APIRouter, HTTPException, File, Request, UploadFile, Form +from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.templating import Jinja2Templates from docker.errors import ImageNotFound, ImageLoadError from manager.exceptions import ( @@ -37,6 +39,7 @@ DAEPLOY_PREFIX, DAEPLOY_TAR_FILE_NAME, DAEPLOY_DEFAULT_INTERNAL_PORT, + get_manager_version, ) from manager.checks import ( check_service_exists_json_body, @@ -54,6 +57,7 @@ LOGGER = logging.getLogger(__name__) ROUTER = APIRouter() +TEMPLATES = Jinja2Templates(directory="manager/templates") @ROUTER.get("/", response_model=List[ServiceResponse]) @@ -87,7 +91,7 @@ def new_service_from_git_repo(service_request: ServiceGitRequest): """ check_service_exists(service_request.name, service_request.version) - image = build_service_image_s2i(service_request.git_url, service_request) + image = build_service_image_s2i(str(service_request.git_url), service_request) start_service_from_image(image, service_request) return "Accepted" @@ -325,6 +329,24 @@ def assign_main_service(service: BaseService): return "OK" +@ROUTER.get("/~logs/view", response_class=HTMLResponse) +def service_logs_view(request: Request, name: str, version: str): + """HTML view that streams a service's logs with a follow/auto-scroll toggle.""" + base = f"/services/~logs?name={quote(name)}&version={quote(version)}" + return TEMPLATES.TemplateResponse( + request=request, + name="logs.html", + context={ + "title": name, + "subtitle": f"v{version}", + "stream_url": f"{base}&follow=true&tail=200", + "full_url": base, + "export_basename": f"{name}_v{version}", + "manager_version": get_manager_version(), + }, + ) + + @ROUTER.get("/~logs", response_class=StreamingResponse) @async_check_service_exists_query_parameters async def read_service_logs( diff --git a/manager/runtime_connectors.py b/manager/runtime_connectors.py index 75382b9..9e96a6d 100644 --- a/manager/runtime_connectors.py +++ b/manager/runtime_connectors.py @@ -73,7 +73,13 @@ async def service_logs(self, service, tail, follow, since): class LocalDockerConnector(ConnectorBase): CLIENT = docker.from_env() - AIO_CLIENT = aiodocker.Docker() + _AIO_CLIENT = None + + @classmethod + def _get_aio_client(cls): + if cls._AIO_CLIENT is None: + cls._AIO_CLIENT = aiodocker.Docker() + return cls._AIO_CLIENT def __init__(self): # Create our own docker network @@ -395,7 +401,7 @@ async def service_logs( AsyncGenerator[str, None]: Async infinite generator if following, else async finite generator. """ - container = await self.AIO_CLIENT.containers.get( + container = await self._get_aio_client().containers.get( create_container_name(service.name, service.version) ) @@ -429,5 +435,42 @@ def manager_logs(self, since: datetime) -> str: manager_container = self._get_manager_container() return manager_container.logs(since=since) + async def manager_logs_stream( + self, + tail: Optional[int] = None, + follow: Optional[bool] = False, + since: Optional[datetime] = None, + ) -> AsyncGenerator[str, None]: + """Stream the manager's own logs, optionally following new output. + + Args: + tail (Optional[int]): Number of lines from the end. Default all. + follow (Optional[bool]): If the logs should be followed. + since (Optional[datetime]): Get logs since given datetime. + + Yields: + AsyncGenerator[str, None]: Async infinite generator if following, + else async finite generator. + """ + manager = self._get_manager_container() + container = await self._get_aio_client().containers.get(manager.id) + + kwargs = { + "tail": tail if tail else "all", + "stdout": True, + "stderr": True, + "follow": follow, + } + if since: + kwargs["since"] = datetime_to_timestamp(since) + logs = container.log(**kwargs) + + if follow: + async for log in logs: + yield log + else: + for log in await logs: + yield log + RTE_CONN = LocalDockerConnector() diff --git a/manager/templates/daeploy_pickle_template/{{cookiecutter.project_name}}/requirements.txt b/manager/templates/daeploy_pickle_template/{{cookiecutter.project_name}}/requirements.txt index 7ee9e67..3bfd672 100644 --- a/manager/templates/daeploy_pickle_template/{{cookiecutter.project_name}}/requirements.txt +++ b/manager/templates/daeploy_pickle_template/{{cookiecutter.project_name}}/requirements.txt @@ -1,2 +1,2 @@ -daeploy==0.4.6 +daeploy==1.3.1 pandas diff --git a/manager/templates/daeploy_pickle_template/{{cookiecutter.project_name}}/service.py b/manager/templates/daeploy_pickle_template/{{cookiecutter.project_name}}/service.py index aeb3b3a..0cbb3e6 100644 --- a/manager/templates/daeploy_pickle_template/{{cookiecutter.project_name}}/service.py +++ b/manager/templates/daeploy_pickle_template/{{cookiecutter.project_name}}/service.py @@ -46,7 +46,7 @@ def predict(data: dict) -> List[Any]: logger.info(f"Recieved data: \n{data_df}") y_pred = model.predict(data_df) logger.info(f"Predicted: {y_pred}") - return list(y_pred) + return y_pred.tolist() if __name__ == "__main__": diff --git a/manager/templates/login.html b/manager/templates/login.html index 7f3168b..d786d63 100644 --- a/manager/templates/login.html +++ b/manager/templates/login.html @@ -1,38 +1,166 @@ - - - Daeploy - - - - -

+ + + + + + Daeploy — Sign in + + + + + + + + + diff --git a/manager/templates/logs.html b/manager/templates/logs.html new file mode 100644 index 0000000..60acc40 --- /dev/null +++ b/manager/templates/logs.html @@ -0,0 +1,292 @@ + + + + + Daeploy — {{ title }} logs + + + + + +
+
+
+ + daeploy +
+ manager v: {{ manager_version }} +
+
+
+
+
+
+
+ {{ title }}{{ subtitle }}
+
+ + + + Live + +
+
+
+
+ +
+
+
+ + + diff --git a/pinned_results.json b/pinned_results.json index 5efd95c..672da14 100644 --- a/pinned_results.json +++ b/pinned_results.json @@ -55,6 +55,21 @@ "websecure" ] }, + { + "entryPoints": [ + "web", + "websecure" + ], + "name": "static_assets@file", + "provider": "file", + "rule": "Host(`localhost`) && PathPrefix(`/assets`)", + "service": "manager_service", + "status": "enabled", + "using": [ + "web", + "websecure" + ] + }, { "entryPoints": [ "web", @@ -106,7 +121,8 @@ "type": "loadbalancer", "usedBy": [ "login_page@file", - "manager@file" + "manager@file", + "static_assets@file" ] }, { @@ -226,6 +242,21 @@ "websecure" ] }, + { + "entryPoints": [ + "web", + "websecure" + ], + "name": "static_assets@file", + "provider": "file", + "rule": "Host(`localhost`) && PathPrefix(`/assets`)", + "service": "manager_service", + "status": "enabled", + "using": [ + "web", + "websecure" + ] + }, { "entryPoints": [ "web", diff --git a/pytest.ini b/pytest.ini index ad16166..c1fd712 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ [pytest] -testpaths = tests/ \ No newline at end of file +testpaths = tests/ +timeout = 180 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 22749eb..b040166 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -5,11 +5,14 @@ pytest pytest-sphinx pytest-asyncio pytest-pinned -pylint==2.7.4 +pytest-timeout +pylint black flake8 darglint streamlit +httpx async_asgi_testclient scikit-learn -nbconvert \ No newline at end of file +nbconvert +ipykernel \ No newline at end of file diff --git a/requirements_docs.txt b/requirements_docs.txt index fe0c8ea..7dfd045 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,4 +1,6 @@ -sphinx +# sphinx-multiversion (unmaintained) breaks on Sphinx 8+ (Config.read signature +# change). Pin Sphinx < 8 until multiversion is replaced/updated. +sphinx<8 sphinx-rtd-theme sphinx-multiversion pip-licenses diff --git a/requirements_manager.txt b/requirements_manager.txt index 0cff61c..d8fad21 100644 --- a/requirements_manager.txt +++ b/requirements_manager.txt @@ -1,14 +1,14 @@ -fastapi==0.65.2 -uvicorn==0.13.3 -docker==4.4.1 -aiodocker==0.19.1 -semver==2.13.0 -python-multipart==0.0.5 +fastapi==0.135.3 +uvicorn==0.44.0 +docker==7.1.0 +aiodocker==0.26.0 +semver==3.0.4 +python-multipart==0.0.26 toml==0.10.2 -sqlalchemy==1.3.22 -dash==1.18.1 -pyjwt==2.0.0 -bcrypt==3.2.0 -jinja2==2.11.3 -cookiecutter==1.7.2 -cryptography==3.3.2 +sqlalchemy==2.0.49 +dash==4.1.0 +pyjwt==2.12.1 +bcrypt==5.0.0 +jinja2==3.1.6 +cookiecutter==2.7.1 +cryptography==46.0.7 diff --git a/setup.py b/setup.py index 310a4a8..1020618 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ "Programming Language :: Python :: 3", "Operating System :: OS Independent", ], - python_requires=">=3.6", + python_requires=">=3.9", install_requires=required, entry_points={ "console_scripts": ["daeploy=daeploy.cli.cli:app"], diff --git a/tests/conftest.py b/tests/conftest.py index 3b0218a..09e965e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,10 +44,10 @@ def test_client_logged_in(test_client: TestClient, auth_enabled, database): response = test_client.post( "/auth/login", data={"username": "admin", "password": "admin"}, - allow_redirects=False, + follow_redirects=False, ) # Check that we have access! - response = test_client.get("/auth/verify", allow_redirects=False) + response = test_client.get("/auth/verify", follow_redirects=False) assert response.status_code == 200 yield test_client # Logs out when removing cookies in parent fixture diff --git a/tests/e2e_test/downstream/downstream.py b/tests/e2e_test/downstream/downstream.py index 222014a..f30b704 100644 --- a/tests/e2e_test/downstream/downstream.py +++ b/tests/e2e_test/downstream/downstream.py @@ -1,6 +1,7 @@ """ File used as a service in e2e tests. """ + import logging import time from pydantic import BaseModel diff --git a/tests/e2e_test/e2e_test.py b/tests/e2e_test/e2e_test.py index 4c8cb42..2e4a4ac 100644 --- a/tests/e2e_test/e2e_test.py +++ b/tests/e2e_test/e2e_test.py @@ -1,5 +1,7 @@ import docker import pytest +import sys +import subprocess import time import requests import uuid @@ -9,7 +11,6 @@ import re import shutil -from setuptools import sandbox from pathlib import Path from typer.testing import CliRunner import nbformat @@ -72,22 +73,54 @@ def cli_auth_login(dummy_manager, cli_auth): @pytest.fixture(scope="module") -def pickle_service(cli_auth_login, headers): +def pickle_service(cli_auth_login, dummy_manager, headers): data = { "name": "pickle", "version": "0.1.0", "port": 8000, - "requirements": ["pandas", "sklearn"], + "requirements": ["pandas", "scikit-learn"], } - requests.request( + response = requests.request( "POST", url="http://localhost/services/~pickle", data=data, headers=headers, files={"file": ("filename", open(THIS_DIR / "pickle_e2e_testing.pkl", "rb"))}, ) - time.sleep(5) # Grace period + assert response.status_code == 202, response.text + + # Poll for the pickle service to be reachable; pandas + scikit-learn + # make the s2i build several minutes long, and the service still needs + # time to start up after the container appears. + deadline = time.time() + 800 + reachable = False + while time.time() < deadline: + try: + r = requests.get( + "http://localhost/services/pickle/openapi.json", + headers=headers, + timeout=5, + ) + if r.status_code == 200: + reachable = True + break + except requests.RequestException: + pass + time.sleep(5) + if not reachable: + client = docker.from_env() + print("Containers:", [c.name for c in client.containers.list(all=True)]) + print( + "dummy_manager logs:\n", + dummy_manager.logs(tail=200).decode(errors="replace"), + ) + for c in client.containers.list(all=True): + if "pickle" in c.name: + print( + f"{c.name} status={c.status} logs:\n", + c.logs(tail=200).decode(errors="replace"), + ) try: yield finally: @@ -168,9 +201,16 @@ def generate_requirements_file_for_service(service_folder): the path to the wheel file which contains the daeploy package. """ # TODO: No need to run the setup twice... - sandbox.run_setup( - str(THIS_DIR.parent.parent / "setup.py"), - ["bdist_wheel", "--dist-dir", str(service_folder)], + subprocess.run( + [ + sys.executable, + "setup.py", + "bdist_wheel", + "--dist-dir", + str(service_folder), + ], + cwd=str(THIS_DIR.parent.parent), + check=True, ) with (service_folder / "requirements.txt").open("w") as file_handle: file_handle.write(WHEEL_FILE_NAME) @@ -263,8 +303,8 @@ def test_disable_http_logs_in_entrypoint( logs = logs("downstream") # Check that the logs from inside the entrypoint are logged. assert "This is a correct log!" in logs - assert '"POST /services/downstream_0.1.0/http_logs_2 HTTP/1.1" 200 OK' in logs - assert '"POST /services/downstream_0.1.0/http_logs HTTP/1.1" 200 OK' not in logs + assert '"POST /http_logs_2 HTTP/1.1" 200 OK' in logs + assert '"POST /http_logs HTTP/1.1" 200 OK' not in logs def test_call_service_multiple_cases( @@ -479,6 +519,7 @@ def test_docs_page_from_service_shows_correct_docs( assert "0.1.0" in service_docs.text +@pytest.mark.timeout(900) def test_service_from_pickle_endpoint(dummy_manager, pickle_service, headers): client = docker.from_env() containers = [con.name for con in client.containers.list()] @@ -492,7 +533,14 @@ def test_service_from_pickle_endpoint(dummy_manager, pickle_service, headers): json=data, headers=headers, ) - assert resp.status_code == 200 + if resp.status_code != 200: + for c in client.containers.list(all=True): + if "pickle" in c.name: + print( + f"{c.name} status={c.status} logs:\n", + c.logs(tail=200).decode(errors="replace"), + ) + assert resp.status_code == 200, resp.text # Test documentation started properly response = requests.get( diff --git a/tests/e2e_test/pickle_e2e_testing.pkl b/tests/e2e_test/pickle_e2e_testing.pkl index 7fc5657..f712db5 100644 Binary files a/tests/e2e_test/pickle_e2e_testing.pkl and b/tests/e2e_test/pickle_e2e_testing.pkl differ diff --git a/tests/manager_test/admin_test.py b/tests/manager_test/admin_test.py index 07f2f30..a6b490f 100644 --- a/tests/manager_test/admin_test.py +++ b/tests/manager_test/admin_test.py @@ -38,7 +38,7 @@ def change_user(client, username, password): client.post( "/auth/login", data={"username": username, "password": password}, - allow_redirects=False, + follow_redirects=False, ) diff --git a/tests/manager_test/auth_test.py b/tests/manager_test/auth_test.py index 13c3e7a..159d201 100644 --- a/tests/manager_test/auth_test.py +++ b/tests/manager_test/auth_test.py @@ -33,7 +33,7 @@ def test_login_page(exclude_middleware): def test_verification_without_auth(database): - response = client.get("/auth/verify", allow_redirects=False) + response = client.get("/auth/verify", follow_redirects=False) assert response.status_code == 200 @@ -43,39 +43,39 @@ def test_failed_login(database, auth_enabled): response = client.post( "/auth/login", data={"username": "admin", "password": "wrongpassword"}, - allow_redirects=False, + follow_redirects=False, ) assert response.status_code == 303 # No access after - response = client.get("/auth/verify", allow_redirects=False) + response = client.get("/auth/verify", follow_redirects=False) assert response.status_code == 303 def test_cookie_token(database, auth_enabled): # No access from beginning - response = client.get("/auth/verify", allow_redirects=False) + response = client.get("/auth/verify", follow_redirects=False) assert response.status_code == 303 # Login response = client.post( "/auth/login", data={"username": "admin", "password": "admin"}, - allow_redirects=False, + follow_redirects=False, ) assert response.status_code == 303 # Check that we have access! - response = client.get("/auth/verify", allow_redirects=False) + response = client.get("/auth/verify", follow_redirects=False) assert response.status_code == 200 # Logout - response = client.get("/auth/logout", allow_redirects=False) + response = client.get("/auth/logout", follow_redirects=False) assert response.status_code == 303 # No access at the end - response = client.get("/auth/verify", allow_redirects=False) + response = client.get("/auth/verify", follow_redirects=False) assert response.status_code == 303 @@ -85,7 +85,7 @@ def test_API_token(clear_cookies, database, auth_enabled): response = client.get( "/auth/verify", headers={"Authorization": f"Bearer mumbojumbo"}, - allow_redirects=True, + follow_redirects=True, ) assert response.status_code == 401 @@ -111,7 +111,7 @@ def test_API_token(clear_cookies, database, auth_enabled): response = client.get( "/auth/verify", headers={"Authorization": f"Bearer {token}"}, - allow_redirects=False, + follow_redirects=False, ) assert response.status_code == 200 @@ -119,7 +119,7 @@ def test_API_token(clear_cookies, database, auth_enabled): response = client.get( "/auth/verify", headers={"Authorization": f"Bearer {token}"}, - allow_redirects=True, + follow_redirects=True, ) assert response.status_code == 200 @@ -131,6 +131,6 @@ def test_API_token(clear_cookies, database, auth_enabled): response = client.get( "/auth/verify", headers={"Authorization": f"Bearer {token}"}, - allow_redirects=True, + follow_redirects=True, ) assert response.status_code == 401 diff --git a/tests/manager_test/endpoint_test.py b/tests/manager_test/endpoint_test.py index 4098bcb..fa66180 100644 --- a/tests/manager_test/endpoint_test.py +++ b/tests/manager_test/endpoint_test.py @@ -10,6 +10,7 @@ import pytest from async_asgi_testclient import TestClient as AsyncTestClient from docker.errors import ImageNotFound +from fastapi.exceptions import ResponseValidationError from fastapi.testclient import TestClient from manager import proxy from manager.routers import logging_api, notification_api, service_api @@ -17,7 +18,6 @@ from manager.constants import DAEPLOY_DEFAULT_S2I_BUILD_IMAGE, get_manager_version from manager.data_models.request_models import BaseService - client = TestClient(app) async_client = AsyncTestClient(app) @@ -195,12 +195,12 @@ def test_post_services_git_request( response = client.post("/services/~git", json=req) assert response.status_code == 202 - service_api.run_s2i.assert_called_with( - url="https://github.com/sclorg/django-ex", - build_image=DAEPLOY_DEFAULT_S2I_BUILD_IMAGE, - name=SERVICE_NAME, - version=SERVICE_VERSION, - ) + service_api.run_s2i.assert_called_once() + call_kwargs = service_api.run_s2i.call_args[1] + assert str(call_kwargs["url"]) == "https://github.com/sclorg/django-ex" + assert call_kwargs["build_image"] == DAEPLOY_DEFAULT_S2I_BUILD_IMAGE + assert call_kwargs["name"] == SERVICE_NAME + assert call_kwargs["version"] == SERVICE_VERSION assert response.json() == "Accepted" @@ -228,12 +228,12 @@ def test_post_services_git_request_changed_builder_image( response = client.post("/services/~git", json=req) assert response.status_code == 202 - service_api.run_s2i.assert_called_with( - url="https://github.com/sclorg/django-ex", - build_image="centos/python-38-centos7", - name=SERVICE_NAME, - version=SERVICE_VERSION, - ) + service_api.run_s2i.assert_called_once() + call_kwargs = service_api.run_s2i.call_args[1] + assert str(call_kwargs["url"]) == "https://github.com/sclorg/django-ex" + assert call_kwargs["build_image"] == "centos/python-38-centos7" + assert call_kwargs["name"] == SERVICE_NAME + assert call_kwargs["version"] == SERVICE_VERSION assert response.json() == "Accepted" @@ -438,8 +438,10 @@ def test_service_delete(mocked_docker_connection, database): ) service_name = SERVICE_NAME service_version = SERVICE_VERSION - response = client.delete( - "/services/", json={"name": service_name, "version": service_version} + response = client.request( + "DELETE", + "/services/", + json={"name": service_name, "version": service_version}, ) assert response.status_code == 200 @@ -462,7 +464,8 @@ def test_service_delete_keep_image(mocked_docker_connection, database): ) service_name = SERVICE_NAME service_version = SERVICE_VERSION - response = client.delete( + response = client.request( + "DELETE", "/services/", json={"name": service_name, "version": service_version}, params={"remove_image": False}, @@ -576,7 +579,7 @@ def test_service_inspection(mocked_docker_connection): service_name = SERVICE_NAME service_version = SERVICE_VERSION - with pytest.raises(pydantic.ValidationError): + with pytest.raises((pydantic.ValidationError, ResponseValidationError)): client.get( f"/services/~inspection?name={service_name}&version={service_version}" ) diff --git a/tests/manager_test/local_docker_connection_test.py b/tests/manager_test/local_docker_connection_test.py index 98791e3..608b99a 100644 --- a/tests/manager_test/local_docker_connection_test.py +++ b/tests/manager_test/local_docker_connection_test.py @@ -211,13 +211,13 @@ async def test_service_logs(local_docker_connection): def check_required_inspection_keys(container_info): - assert set(InspectResponse.schema()["required"]).issubset( + assert set(InspectResponse.model_json_schema()["required"]).issubset( set(container_info.keys()) ) - assert set(NetworkSettingsResponse.schema()["required"]).issubset( + assert set(NetworkSettingsResponse.model_json_schema()["required"]).issubset( set(container_info["NetworkSettings"].keys()) ) - assert set(StateResponse.schema()["required"]).issubset( + assert set(StateResponse.model_json_schema()["required"]).issubset( set(container_info["State"].keys()) ) diff --git a/tests/manager_test/notifications_test.py b/tests/manager_test/notifications_test.py index 612c608..2877c8c 100644 --- a/tests/manager_test/notifications_test.py +++ b/tests/manager_test/notifications_test.py @@ -97,7 +97,7 @@ def test_email_notification_not_send_when_frozen(email_func, notifications_dict) notification_api.new_notification(notification_3) notification_api.new_notification(notification_3) # The email func is only called once! - email_func.called_once() + email_func.assert_called_once() @patch("manager.routers.notification_api._send_notification_as_email") diff --git a/tests/manager_test/test_ui_redesign.py b/tests/manager_test/test_ui_redesign.py new file mode 100644 index 0000000..6136ad0 --- /dev/null +++ b/tests/manager_test/test_ui_redesign.py @@ -0,0 +1,213 @@ +# tests/manager_test/test_ui_redesign.py +from pathlib import Path + +ASSETS = Path("manager/assets") + + +def test_tokens_css_defines_palette(): + css = (ASSETS / "tokens.css").read_text() + for var, val in [ + ("--ground", "#0E1320"), + ("--accent", "#5EE6D0"), + ("--text", "#E7ECF5"), + ("--crit", "#F2585B"), + ]: + assert f"{var}:{val}" in css.replace(" ", ""), f"missing {var}" + assert "@font-face" in css + assert "url(fonts/" in css.replace(" ", ""), "fonts must be referenced relatively" + assert "http://" not in css and "https://" not in css + + +def test_fonts_bundled(): + woff2 = list((ASSETS / "fonts").glob("*.woff2")) + assert len(woff2) >= 4, "expected Inter + JetBrains Mono weights" + assert all(f.stat().st_size > 5000 for f in woff2), "woff2 files look empty" + + +def test_logo_is_local_svg(): + svg = (ASSETS / "daeploy_mark.svg").read_text() + assert " Docker "all". + # Passing the literal tail=all 422s (the param is typed int). + assert '"/services/~logs?name=demo&version=0.1.0"' in r.text + assert "tail=all" not in r.text + assert 'EXPORT_BASENAME = "demo_v0.1.0"' in r.text + + +def test_manager_logs_view_full_url_and_basename(test_client): + r = test_client.get("/logs/view") + assert r.status_code == 200 + assert '"/logs/stream"' in r.text + assert "tail=all" not in r.text + assert 'EXPORT_BASENAME = "manager"' in r.text + + +def test_logs_toolbar_has_search_and_export(): + html = TPL.joinpath("logs.html").read_text() + assert 'id="searchBox"' in html + assert 'id="matchCount"' in html + assert 'id="exportBtn"' in html + assert "function runSearch" in html + assert "function startStream" in html # cancellable live tail + assert "EXPORT_BASENAME" in html and "FULL_URL" in html + assert "a.download" in html # triggers a file download + assert "createTextNode" in html # XSS-safe highlight + + +def test_dashboard_grid_widens_services_and_fills_viewport(): + css = (ASSETS / "dashboard_styles.css").read_text().replace(" ", "") + # Services hero is wider than the slim Notifications rail + assert "grid-template-columns:2.6fr0.9fr" in css + assert "grid-template-columns:1.85fr1fr" not in css + # Page fills the viewport height (minus the banner); content is biased to + # the top with a small, capped gap (not centered, which floated the panels + # to mid-screen and left a large gap above on tall monitors). + assert "min-height:calc(100vh-4rem)" in css + assert "box-sizing:border-box" in css + assert "margin-block:auto" not in css # no full-height vertical centering + assert "margin-top:clamp(" in css # small capped top gap on .grid + + +def test_notifications_panel_has_explanation(): + from manager.routers import dashboard_api + + layout = str(dashboard_api.app.layout) + assert "Alerts services raise" in layout # one-line description of the panel + css = (ASSETS / "dashboard_styles.css").read_text() + assert ".panel-sub" in css + + +def test_service_rows_are_compact(): + css = (ASSETS / "dashboard_styles.css").read_text().replace(" ", "") + assert ".svc{" in css + svc_block = css.split(".svc{", 1)[1].split("}", 1)[0] + # tight vertical row padding so more services fit without scrolling + assert "padding:.5rem1.2rem" in svc_block + + +def test_service_links_look_like_buttons(): + css = (ASSETS / "dashboard_styles.css").read_text().replace(" ", "") + # .lnk is now a bordered pill, not underline-on-hover text + assert ".lnk{" in css + lnk_block = css.split(".lnk{", 1)[1].split("}", 1)[0] + assert "border:1pxsolid" in lnk_block + assert "border-radius:" in lnk_block + # keyboard focus ring, matching .act + assert ".lnk:focus-visible{" in css diff --git a/tests/sdk_test/cli_test.py b/tests/sdk_test/cli_test.py index 7ecc231..1d141b9 100644 --- a/tests/sdk_test/cli_test.py +++ b/tests/sdk_test/cli_test.py @@ -143,7 +143,8 @@ def test_version_flag_without_manager(): ["--version"], ) assert result.exit_code == 0 - assert "Manager" not in result.stdout + assert "SDK version" in result.stdout + assert "Manager version:" not in result.stdout def test_deploy_from_git_source(dummy_manager, cli_auth_login, clean_services): @@ -814,7 +815,7 @@ def test_logs_date_format(cli_auth_login, clean_services): ["logs", "test_service", "1.0.0", "--date", "2020/01/24"], ) assert logs.exit_code == 2 - assert "invalid datetime format" in logs.stdout + assert "does not match the formats" in logs.output logs = runner.invoke( app, ["logs", "test_service", "1.0.0", "--date", "1970-01-24"], @@ -847,11 +848,18 @@ def kill_service(): ], ) - threading.Thread(target=kill_service).start() + killer = threading.Thread(target=kill_service) + killer.start() streamed_logs = runner.invoke( app, ["logs", "test_service", "1.0.0", "--follow"], ) + # Wait for the background "kill" invoke to finish before returning. It uses + # the same module-level CliRunner, and CliRunner.isolation() swaps the + # process-global sys.stdout/sys.stderr. If the thread is left unjoined, its + # isolation teardown races with the next test's invoke and closes that + # invoke's stream -> "ValueError: I/O operation on closed file". + killer.join() assert len(streamed_logs.stdout) > len(first_logs.stdout) diff --git a/tests/sdk_test/daeploy_test.py b/tests/sdk_test/daeploy_test.py index d16c186..9fedf50 100644 --- a/tests/sdk_test/daeploy_test.py +++ b/tests/sdk_test/daeploy_test.py @@ -508,7 +508,7 @@ def test_local_invocation_pydantic_validation(): assert valid_entrypoint_method_args(32, "Urban") == "hello" # Args of wrong type! - with pytest.raises(pydantic.error_wrappers.ValidationError): + with pytest.raises(pydantic.ValidationError): wrapped(32, "Urban") assert wrapped("Urban", 32) == "hello" @@ -589,7 +589,8 @@ def test_entrypoint_get(): client = TestClient(service.app) req = {"name": "Rune", "age": 100} - response = client.get( + response = client.request( + "GET", "/valid_entrypoint_method_args", json=req, headers={"accept": "application/json"}, @@ -734,28 +735,44 @@ def test_edge_case_type_storing(database): assert db.read_from_ts("my.normal.float")[-1].value == 10.10 -def test_read_timerange(database): +def test_read_timerange(database, monkeypatch): + + # Root cause of this test's historic flakiness: clean_database() is the only + # code path that deletes rows, and it re-reads DAEPLOY_SERVICE_DB_TABLE_LIMIT + # on every call. A leaked periodic clean (e.g. with the "1seconds" limit that + # db_limit_second sets) trims the OLDEST rows as they age past the limit, + # mid-test — so successive reads disagreed and a wider window could return + # FEWER rows than a narrower one (200 == 195/191/...). Pin a huge limit for + # the duration of this test so any concurrent clean is a no-op for our data. + monkeypatch.setenv("DAEPLOY_SERVICE_DB_TABLE_LIMIT", "36500days") before = datetime.datetime.utcnow() - mid = None - for i in range(200): - db.write_to_ts("float", float(i), datetime.datetime.utcnow()) - if i == 100: - mid = datetime.datetime.utcnow() + # Explicit, strictly-increasing timestamps: the timestamp column is the + # table's primary key, so repeated utcnow() within one microsecond would + # collide on the PK. + timestamps = [before + datetime.timedelta(milliseconds=i + 1) for i in range(200)] + mid = timestamps[100] + + for i, timestamp in enumerate(timestamps): + db.write_to_ts("float", float(i), timestamp) await_database_queue() - after = datetime.datetime.utcnow() + after = timestamps[-1] + datetime.timedelta(milliseconds=1) - # Check that default values behave according to expectations + # Check the from_time/to_time defaults: open-ended from_time/to_time default + # to datetime.min / utcnow(), so all three "full window" forms return the + # same set; narrowing to_time to the midpoint returns a strict, non-empty + # subset. + full = len(db.read_from_ts("float", from_time=before, to_time=after)) assert ( - len(db.read_from_ts("float", from_time=before, to_time=after)) + full == len(db.read_from_ts("float", to_time=after)) == len(db.read_from_ts("float", from_time=before)) == 200 ) - assert len(db.read_from_ts("float", from_time=before, to_time=mid)) < 200 + assert 0 < len(db.read_from_ts("float", from_time=before, to_time=mid)) < 200 def test_database_limit_rows(database, db_limit_rows):