diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e35ce0b..01ae5c3 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Deploy to Cloudflare Pages env: diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 015f79a..3d7d985 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Log in to GHCR - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -40,6 +40,7 @@ jobs: run: | docker build --platform ${{ matrix.platform }} \ --build-arg BUILD_CHANNEL=dev \ + --build-arg BUILD_LABEL="v2 dev" \ --build-arg GIT_SHA=${{ steps.sha.outputs.short }} \ -t ghcr.io/kylejschultz/nestview:dev-${{ steps.sha.outputs.short }}-${{ matrix.suffix }} \ -f Dockerfile \ @@ -52,7 +53,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Log in to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 diff --git a/.github/workflows/publish-pr-preview.yml b/.github/workflows/publish-pr-preview.yml new file mode 100644 index 0000000..46c8d06 --- /dev/null +++ b/.github/workflows/publish-pr-preview.yml @@ -0,0 +1,89 @@ +name: Publish PR Preview + +on: + pull_request: + branches: [dev] + types: [opened, synchronize, reopened] + +permissions: + contents: read + packages: write + +concurrency: + group: publish-pr-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + suffix: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + suffix: arm64 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set preview tags + id: preview + run: | + echo "short=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + + - name: Build and push + run: | + docker build --platform ${{ matrix.platform }} \ + --build-arg BUILD_CHANNEL=pr \ + --build-arg BUILD_LABEL="PR #${{ steps.preview.outputs.number }}" \ + --build-arg GIT_SHA=${{ steps.preview.outputs.short }} \ + -t ghcr.io/kylejschultz/nestview:pr-${{ steps.preview.outputs.number }}-${{ steps.preview.outputs.short }}-${{ matrix.suffix }} \ + -f Dockerfile \ + . + docker push ghcr.io/kylejschultz/nestview:pr-${{ steps.preview.outputs.number }}-${{ steps.preview.outputs.short }}-${{ matrix.suffix }} + + merge: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-24.04 + needs: [build] + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set preview tags + id: preview + run: | + echo "short=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Merge manifest + run: | + docker buildx imagetools create \ + -t ghcr.io/kylejschultz/nestview:pr-${{ steps.preview.outputs.number }} \ + -t ghcr.io/kylejschultz/nestview:pr-${{ steps.preview.outputs.number }}-${{ steps.preview.outputs.short }} \ + ghcr.io/kylejschultz/nestview:pr-${{ steps.preview.outputs.number }}-${{ steps.preview.outputs.short }}-amd64 \ + ghcr.io/kylejschultz/nestview:pr-${{ steps.preview.outputs.number }}-${{ steps.preview.outputs.short }}-arm64 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 74e355c..30acd9a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,12 +29,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} - name: Log in to GHCR - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -70,7 +70,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} @@ -108,7 +108,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.ref }} @@ -144,7 +144,7 @@ jobs: changelog = changelog_file.read() section_match = re.search( - rf"^## \[{re.escape(version)}\].*?\n(.*?)(?=\n---\n## |\Z)", + rf"^## \[{re.escape(version)}\].*?\n(.*?)(?=\n(?:---\n)?## \[|\Z)", changelog, re.MULTILINE | re.DOTALL, ) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 598d41c..f0daf70 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -25,7 +25,7 @@ jobs: fi - name: Create or update release PR - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: token: ${{ secrets.RELEASE_PLEASE_TOKEN }} config-file: release-please-config.json diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 663beae..cae6b4d 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -19,10 +19,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" cache: pip @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -63,7 +63,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build image run: | @@ -79,6 +79,7 @@ jobs: image-ref: nestview:security-scan format: table ignore-unfixed: true + cache: false severity: HIGH,CRITICAL vuln-type: os,library exit-code: "1" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b2db7f..063f11f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" cache: pip @@ -37,7 +37,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -60,7 +60,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build image run: | diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..d8b1ea5 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,5 @@ +# False positives in the embedded SBOM of python:3.14-slim. The runtime image +# does not contain either package; verified against the upstream base image on +# 2026-08-13. Reassess after the upstream SBOM refreshes. +GHSA-6v7p-g79w-8964 exp:2026-09-13 +CVE-2025-47273 exp:2026-09-13 diff --git a/Dockerfile b/Dockerfile index e4fa7fe..f8254b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,16 +13,20 @@ FROM python:3.14-slim ARG BUILD_CHANNEL ENV BUILD_CHANNEL=${BUILD_CHANNEL} -ARG GIT_SHA=unknown -ENV BUILD_SHA=${GIT_SHA} - -RUN pip install --no-cache-dir --upgrade pip +ARG BUILD_LABEL +ENV BUILD_LABEL=${BUILD_LABEL} WORKDIR /app # Backend dependencies COPY backend/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --upgrade "setuptools>=78.1.1" \ + && pip install --no-cache-dir -r requirements.txt \ + && python -m pip uninstall -y pip \ + && find /usr/local/lib/python3.14/site-packages -maxdepth 1 -name "pip*" -exec rm -rf {} + + +ARG GIT_SHA=unknown +ENV BUILD_SHA=${GIT_SHA} # Backend source COPY backend/ . diff --git a/backend/api/containers.py b/backend/api/containers.py index 283ceb6..0f385d3 100644 --- a/backend/api/containers.py +++ b/backend/api/containers.py @@ -16,7 +16,6 @@ "previous_docker_id", # internal re-association field, not for API consumers "image_digest", # raw local image ID (sha256 of config blob), not surfaced in UI "registry_digest", # raw registry manifest digest, not surfaced in UI - "last_pulled", # not surfaced in UI } diff --git a/backend/api/system.py b/backend/api/system.py index 8a23841..9e8570f 100644 --- a/backend/api/system.py +++ b/backend/api/system.py @@ -1,10 +1,9 @@ -import os from datetime import datetime, timezone import docker from fastapi import APIRouter -from build_info import APP_VERSION, BUILD_SHA, APP_START_TIME +from build_info import APP_VERSION, BUILD_CHANNEL, BUILD_DISPLAY, BUILD_LABEL, BUILD_SHA, APP_START_TIME from database import DB_PATH as _DB_PATH router = APIRouter(prefix="/api/system", tags=["system"]) @@ -26,8 +25,10 @@ def get_system_info() -> dict: return { "version": APP_VERSION, - "build_channel": os.environ.get("BUILD_CHANNEL", ""), + "build_channel": BUILD_CHANNEL, + "build_label": BUILD_LABEL, "build_sha": BUILD_SHA, + "display_version": BUILD_DISPLAY, "uptime_seconds": uptime_seconds, "db_size_bytes": db_size_bytes, "docker_connected": docker_connected, diff --git a/backend/build_info.py b/backend/build_info.py index 681785a..bf8b592 100644 --- a/backend/build_info.py +++ b/backend/build_info.py @@ -4,9 +4,17 @@ _version_file = Path("/app/VERSION") _raw_version = _version_file.read_text().strip() if _version_file.exists() else "dev" -_build_channel = os.environ.get("BUILD_CHANNEL", "") -APP_VERSION = f"{_raw_version}-{_build_channel}" if _build_channel else _raw_version +APP_VERSION = _raw_version +BUILD_CHANNEL = os.environ.get("BUILD_CHANNEL", "") +BUILD_LABEL = os.environ.get("BUILD_LABEL", "") _raw_sha = os.environ.get("BUILD_SHA", "") BUILD_SHA: str | None = _raw_sha if _raw_sha and _raw_sha != "unknown" else None +BUILD_DISPLAY = ( + f"{BUILD_LABEL} · {BUILD_SHA}" + if BUILD_LABEL and BUILD_SHA + else BUILD_LABEL + or (f"{BUILD_CHANNEL} · {BUILD_SHA}" if BUILD_CHANNEL and BUILD_SHA else "") + or (BUILD_CHANNEL if BUILD_CHANNEL else f"v{APP_VERSION}") +) APP_START_TIME = datetime.now(timezone.utc) diff --git a/backend/main.py b/backend/main.py index 15de869..a470e4e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -from build_info import APP_VERSION, BUILD_SHA +from build_info import APP_VERSION, BUILD_CHANNEL, BUILD_DISPLAY, BUILD_LABEL, BUILD_SHA from database import create_db_and_tables, engine from api import containers, logs, events, settings, actions, admin, stack_actions, operations, analytics as analytics_router from api import auth as auth_router @@ -168,7 +168,13 @@ def version(): # Intentionally public — version info is useful for unauthenticated users # (e.g., confirming an upgrade took effect from the login page) and carries # no meaningful attack surface beyond what's visible in the image tag. - return {"version": APP_VERSION, "build_sha": BUILD_SHA} + return { + "version": APP_VERSION, + "build_channel": BUILD_CHANNEL, + "build_label": BUILD_LABEL, + "build_sha": BUILD_SHA, + "display_version": BUILD_DISPLAY, + } @app.get("/api/health") diff --git a/backend/migrations.py b/backend/migrations.py index 117790d..9ef4f59 100644 --- a/backend/migrations.py +++ b/backend/migrations.py @@ -394,6 +394,34 @@ def _migrate_016(engine: Engine) -> None: logger.info("migration 016: backfilled legacy Discord notification destination") +def _migrate_017(engine: Engine) -> None: + """Add container operational metadata for detail inspector context.""" + inspector = inspect(engine) + existing_cols = {col["name"] for col in inspector.get_columns("container")} + + columns_to_add = [ + ("health_status", "TEXT"), + ("restart_policy", "TEXT"), + ("exit_code", "INTEGER"), + ("oom_killed", "INTEGER NOT NULL DEFAULT 0"), + ("finished_at", "TEXT"), + ("container_error", "TEXT"), + ] + + with engine.connect() as conn: + for col_name, col_type in columns_to_add: + if col_name in existing_cols: + logger.info("migration 017: column %s already present, skipping", col_name) + continue + try: + conn.execute(text(f"ALTER TABLE container ADD COLUMN {col_name} {col_type}")) + logger.info("migration 017: added column %s", col_name) + except Exception as e: + logger.warning("migration 017: could not add column %s: %s", col_name, e) + raise + conn.commit() + + MIGRATIONS: list[tuple[str, Callable]] = [ ("001", _migrate_001), ("002", _migrate_002), @@ -411,6 +439,7 @@ def _migrate_016(engine: Engine) -> None: ("014", _migrate_014), ("015", _migrate_015), ("016", _migrate_016), + ("017", _migrate_017), ] diff --git a/backend/models.py b/backend/models.py index 185ce0d..d824a09 100644 --- a/backend/models.py +++ b/backend/models.py @@ -43,6 +43,12 @@ class Container(SQLModel, table=True): net_rx_bytes: Optional[int] = 0 net_tx_bytes: Optional[int] = 0 previous_docker_id: Optional[str] = None + health_status: Optional[str] = None + restart_policy: Optional[str] = None + exit_code: Optional[int] = None + oom_killed: bool = False + finished_at: Optional[datetime] = None + container_error: Optional[str] = None class ContainerLog(SQLModel, table=True): diff --git a/backend/requirements.txt b/backend/requirements.txt index 3717e41..ee08b48 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,15 +1,17 @@ -fastapi==0.136.1 +fastapi==0.141.1 itsdangerous==2.2.0 bcrypt==5.0.0 slowapi==0.1.10 # Explicit starlette pin to fix CVEs GHSA-f96h-pmfr-66vw and GHSA-2c2j-9gv5-cj73. # fastapi 0.136.1 allows starlette>=0.40.0,<1.0.0 so this is within range. -starlette>=1.3.1 -uvicorn[standard]==0.51.0 -sqlmodel==0.0.38 +starlette>=1.6.0 +uvicorn[standard]==0.52.1 +sqlmodel==0.0.39 apscheduler==3.11.3 httpx==0.28.1 pydantic==2.13.4 -docker==7.1.0 +docker==7.2.0 # Explicit requests pin to fix CVE GHSA-gc5v-m9x4-r6x2 (transitive via docker SDK). requests>=2.34.2 +# Explicit msgpack pin to fix GHSA-6v7p-g79w-8964 (transitive via uvicorn[standard]). +msgpack>=1.2.1 diff --git a/backend/services/collector.py b/backend/services/collector.py index f358b2c..1f2051e 100644 --- a/backend/services/collector.py +++ b/backend/services/collector.py @@ -91,6 +91,15 @@ def _net_bytes(stats: dict) -> tuple[int, int]: return rx, tx +def _restart_policy(attrs: dict) -> Optional[str]: + policy = attrs.get("HostConfig", {}).get("RestartPolicy", {}) or {} + name = policy.get("Name") or "no" + retry_count = policy.get("MaximumRetryCount") + if name == "on-failure" and retry_count: + return f"{name}:{retry_count}" + return name + + # ── Container data collection ────────────────────────────────────────────────── @@ -98,6 +107,8 @@ def _collect_one(container) -> Optional[dict]: try: container.reload() raw_stats = container.stats(stream=False) + attrs = container.attrs + state = attrs.get("State", {}) cpu = _cpu_percent(raw_stats) mem_usage, mem_limit = _mem_bytes(raw_stats) @@ -110,19 +121,19 @@ def _collect_one(container) -> Optional[dict]: for cport, h in ports_map.items() if h ] - mounts = container.attrs.get("Mounts", []) + mounts = attrs.get("Mounts", []) volumes = [ f"{m.get('Source', '?')}:{m.get('Destination', '?')}" for m in mounts ] networks = list( - container.attrs.get("NetworkSettings", {}).get("Networks", {}).keys() + attrs.get("NetworkSettings", {}).get("Networks", {}).keys() ) image_tags = container.image.tags image = ( image_tags[0] if image_tags - else container.attrs.get("Config", {}).get("Image", "unknown") + else attrs.get("Config", {}).get("Image", "unknown") ) return { @@ -131,8 +142,8 @@ def _collect_one(container) -> Optional[dict]: "name": container.name.lstrip("/"), "image": image, "status": container.status, - "state": container.attrs["State"]["Status"], - "restart_count": container.attrs.get("RestartCount", 0), + "state": state.get("Status", container.status), + "restart_count": attrs.get("RestartCount", 0), "cpu_percent": cpu, "mem_usage": mem_usage, "mem_limit": mem_limit, @@ -141,10 +152,16 @@ def _collect_one(container) -> Optional[dict]: "networks": json.dumps(networks), "compose_project": labels.get("com.docker.compose.project"), "compose_service": labels.get("com.docker.compose.service"), - "created_at": container.attrs.get("Created"), - "started_at": container.attrs.get("State", {}).get("StartedAt"), + "created_at": attrs.get("Created"), + "started_at": state.get("StartedAt"), "net_rx_bytes": net_rx, "net_tx_bytes": net_tx, + "health_status": state.get("Health", {}).get("Status"), + "restart_policy": _restart_policy(attrs), + "exit_code": state.get("ExitCode"), + "oom_killed": bool(state.get("OOMKilled", False)), + "finished_at": state.get("FinishedAt"), + "container_error": state.get("Error") or None, } except Exception as exc: name = _safe_name(getattr(container, "name", "?")) @@ -238,6 +255,12 @@ def _apply_batch(containers_data: list[dict]) -> None: existing.started_at = _parse_dt(c["started_at"]) existing.net_rx_bytes = c["net_rx_bytes"] existing.net_tx_bytes = c["net_tx_bytes"] + existing.health_status = c.get("health_status") + existing.restart_policy = c.get("restart_policy") + existing.exit_code = c.get("exit_code") + existing.oom_killed = bool(c.get("oom_killed", False)) + existing.finished_at = _parse_dt(c.get("finished_at")) + existing.container_error = c.get("container_error") existing.last_seen = datetime.utcnow() session.add(existing) else: @@ -310,6 +333,12 @@ def _apply_batch(containers_data: list[dict]) -> None: old.started_at = _parse_dt(c["started_at"]) old.net_rx_bytes = c["net_rx_bytes"] old.net_tx_bytes = c["net_tx_bytes"] + old.health_status = c.get("health_status") + old.restart_policy = c.get("restart_policy") + old.exit_code = c.get("exit_code") + old.oom_killed = bool(c.get("oom_killed", False)) + old.finished_at = _parse_dt(c.get("finished_at")) + old.container_error = c.get("container_error") old.last_seen = datetime.utcnow() session.add(old) @@ -358,6 +387,12 @@ def _apply_batch(containers_data: list[dict]) -> None: started_at=_parse_dt(c["started_at"]), net_rx_bytes=c["net_rx_bytes"], net_tx_bytes=c["net_tx_bytes"], + health_status=c.get("health_status"), + restart_policy=c.get("restart_policy"), + exit_code=c.get("exit_code"), + oom_killed=bool(c.get("oom_killed", False)), + finished_at=_parse_dt(c.get("finished_at")), + container_error=c.get("container_error"), last_seen=datetime.utcnow(), ) session.add(new_container) diff --git a/backend/tests/test_collector_network.py b/backend/tests/test_collector_network.py index 8e2599b..fedccef 100644 --- a/backend/tests/test_collector_network.py +++ b/backend/tests/test_collector_network.py @@ -65,6 +65,12 @@ def _container_data(started_at: str, rx: int, tx: int) -> dict: "started_at": started_at, "net_rx_bytes": rx, "net_tx_bytes": tx, + "health_status": "healthy", + "restart_policy": "unless-stopped", + "exit_code": 0, + "oom_killed": False, + "finished_at": "2026-06-28T10:00:00Z", + "container_error": None, } @@ -180,3 +186,32 @@ def test_apply_batch_ignores_stale_snapshot_for_previous_docker_id(collector_eng assert containers[0].previous_docker_id == "docker-old" assert len(events) == 1 assert events[0].details == "Container recreated: docker-old -> docker-new" + + +def test_apply_batch_persists_operational_metadata(collector_engine): + stopped = _container_data( + started_at="2026-06-28T10:00:00Z", + rx=0, + tx=0, + ) + stopped.update({ + "status": "exited", + "state": "exited", + "health_status": None, + "restart_policy": "on-failure:3", + "exit_code": 137, + "oom_killed": True, + "finished_at": "2026-06-28T11:30:00Z", + "container_error": "out of memory", + }) + + collector._apply_batch([stopped]) + + with Session(collector_engine) as session: + container = session.exec(select(Container)).one() + + assert container.restart_policy == "on-failure:3" + assert container.exit_code == 137 + assert container.oom_killed is True + assert container.finished_at == datetime(2026, 6, 28, 11, 30, 0) + assert container.container_error == "out of memory" diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index ac65a82..c1c85ed 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -30,7 +30,7 @@ def test_run_migrations_advances_schema_version_and_is_idempotent(): ).first() assert schema_version is not None - assert schema_version.value == "016" + assert schema_version.value == "017" assert analytics_last_ping is not None assert analytics_last_ping.value == "" assert retention is not None @@ -43,6 +43,14 @@ def test_run_migrations_advances_schema_version_and_is_idempotent(): } assert "operation" in inspect(engine).get_table_names() assert "notification_destination" in inspect(engine).get_table_names() + assert { + "health_status", + "restart_policy", + "exit_code", + "oom_killed", + "finished_at", + "container_error", + }.issubset({col["name"] for col in inspect(engine).get_columns("container")}) assert discord_destination is not None assert discord_destination.name == "Discord" assert "ix_operation_running_target" in { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e0ec0a..9e8620c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,22 +9,21 @@ "version": "0.1.0", "dependencies": { "@tanstack/react-query": "^5.101.2", - "react": "19.2.7", - "react-dom": "19.2.7", + "react": "19.2.8", + "react-dom": "19.2.8", "react-icons": "^5.7.0", - "react-router-dom": "^7.18.1", "recharts": "^3.9.2" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.3", - "autoprefixer": "^10.5.0", - "postcss": "^8.5.14", + "autoprefixer": "^10.5.4", + "postcss": "^8.5.26", "tailwindcss": "^4.3.2", - "typescript": "^6.0.3", - "vite": "^8.0.16" + "typescript": "^7.0.2", + "vite": "^8.2.0" } }, "node_modules/@alloc/quick-lru": { @@ -40,40 +39,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -124,29 +89,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", "dev": true, "license": "MIT", "funding": { @@ -180,9 +126,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", "cpu": [ "arm64" ], @@ -197,9 +143,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", "cpu": [ "arm64" ], @@ -214,9 +160,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", "cpu": [ "x64" ], @@ -231,9 +177,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", "cpu": [ "x64" ], @@ -248,9 +194,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", "cpu": [ "arm" ], @@ -265,9 +211,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", "cpu": [ "arm64" ], @@ -285,9 +231,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", "cpu": [ "arm64" ], @@ -305,9 +251,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", "cpu": [ "ppc64" ], @@ -325,9 +271,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", "cpu": [ "s390x" ], @@ -345,9 +291,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", "cpu": [ "x64" ], @@ -365,9 +311,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", "cpu": [ "x64" ], @@ -385,9 +331,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", "cpu": [ "arm64" ], @@ -401,29 +347,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", "cpu": [ "arm64" ], @@ -438,9 +365,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", "cpu": [ "x64" ], @@ -850,17 +777,6 @@ "react": "^18 || ^19" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -925,9 +841,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -935,9 +851,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -950,6 +866,346 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -977,9 +1233,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", "dev": true, "funding": [ { @@ -997,8 +1253,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -1014,9 +1270,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.21", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", - "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.8.tgz", + "integrity": "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1027,9 +1283,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -1047,10 +1303,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -1061,9 +1317,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001790", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", - "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -1090,19 +1346,6 @@ "node": ">=6" } }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1248,9 +1491,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.344", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", - "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "version": "1.5.398", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", + "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", "dev": true, "license": "ISC" }, @@ -1649,9 +1892,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -1668,11 +1911,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/picocolors": { "version": "1.1.1", @@ -1682,9 +1928,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -1695,9 +1941,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -1715,7 +1961,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1731,24 +1977,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-icons": { @@ -1790,44 +2036,6 @@ } } }, - "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", - "license": "MIT", - "dependencies": { - "react-router": "7.18.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, "node_modules/recharts": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", @@ -1880,13 +2088,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1896,21 +2104,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, "node_modules/scheduler": { @@ -1919,12 +2126,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1979,26 +2180,39 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/update-browserslist-db": { @@ -2064,16 +2278,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "bin": { @@ -2090,7 +2304,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2140,6 +2354,279 @@ "optional": true } } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 11b5745..da3f685 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,21 +10,20 @@ }, "dependencies": { "@tanstack/react-query": "^5.101.2", - "react": "19.2.7", - "react-dom": "19.2.7", + "react": "19.2.8", + "react-dom": "19.2.8", "react-icons": "^5.7.0", - "react-router-dom": "^7.18.1", "recharts": "^3.9.2" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.3", - "autoprefixer": "^10.5.0", - "postcss": "^8.5.14", + "autoprefixer": "^10.5.4", + "postcss": "^8.5.26", "tailwindcss": "^4.3.2", - "typescript": "^6.0.3", - "vite": "^8.0.16" + "typescript": "^7.0.2", + "vite": "^8.2.0" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a37d093..e5923f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,21 +1,25 @@ import { useState, useEffect } from "react"; -import { Routes, Route, Navigate, useNavigate } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Dashboard from "./pages/Dashboard"; +import Services from "./pages/Services"; +import Containers from "./pages/Containers"; import ContainerDetail from "./pages/ContainerDetail"; +import PlaceholderPage from "./pages/PlaceholderPage"; import Settings from "./pages/Settings"; import Setup from "./pages/Setup"; import Login from "./pages/Login"; -import Header from "./components/Header"; +import AppShell from "./components/AppShell"; import SetupWizard from "./components/SetupWizard"; import { TimezoneProvider } from "./TimezoneContext"; import { AuthContext } from "./AuthContext"; import { api, setOn401Handler } from "./api"; import type { AuthStatus, MeResponse, WizardStatus } from "./types"; +import { Redirect, useLocation, useNavigate } from "./router"; export default function App() { const queryClient = useQueryClient(); const navigate = useNavigate(); + const location = useLocation(); // Once the wizard is dismissed in this session, don't re-show it without a reload. const [wizardDismissed, setWizardDismissed] = useState(false); @@ -118,31 +122,35 @@ export default function App() { const isAuthenticated = authStatus?.auth_mode === "none" || meData?.authenticated === true; if (!isAuthenticated) { - return ( - - } /> - } /> - - ); + if (location.pathname === "/login") return ; + return ; + } + + function renderRoute() { + if (location.pathname === "/") return ; + if (location.pathname === "/dashboard") return ; + if (location.pathname === "/services") return ; + if (location.pathname === "/containers") return ; + if (location.pathname.startsWith("/containers/")) return ; + if (location.pathname === "/hosts") return ; + if (location.pathname === "/alerts") return ; + if (location.pathname === "/notifications") return ; + if (location.pathname === "/integrations") return ; + if (location.pathname === "/settings") return ; + if (location.pathname === "/setup" || location.pathname === "/login") return ; + return ; } // Full app (auth_mode "none" or authenticated) return ( -
-
-
- - } /> - } /> - } /> - } /> - } /> - + +
+ {renderRoute()}
{showWizard && setWizardDismissed(true)} />} -
+
); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f822f48..c8e0272 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -94,7 +94,13 @@ async function del(path: string): Promise { } export const api = { - version: () => fetch(`${BASE}/version`).then((r) => r.json()) as Promise<{ version: string; build_sha: string | null }>, + version: () => fetch(`${BASE}/version`).then((r) => r.json()) as Promise<{ + version: string; + build_channel: string; + build_label: string; + build_sha: string | null; + display_version: string; + }>, containers: { list: () => get("/containers"), get: (id: string) => get(`/containers/${id}`), diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..df22327 --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -0,0 +1,195 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Link, NavLink, useLocation } from "../router"; +import { useQuery } from "@tanstack/react-query"; +import { + FiActivity, + FiBell, + FiBox, + FiChevronLeft, + FiChevronRight, + FiCpu, + FiGrid, + FiLayers, + FiLogOut, + FiSettings, + FiSliders, + FiZap, +} from "react-icons/fi"; +import { api } from "../api"; +import { useAuth } from "../AuthContext"; +import type { Container } from "../types"; +import NestviewLogo from "./NestviewLogo"; + +interface AppShellProps { + children: ReactNode; + onLogout?: () => void; + authMode?: string; +} + +const SIDEBAR_COLLAPSED_KEY = "nestview.sidebarCollapsed"; + +const NAV_ITEMS = [ + { to: "/dashboard", label: "Dashboard", icon: FiGrid }, + { to: "/services", label: "Services", icon: FiLayers }, + { to: "/containers", label: "Containers", icon: FiBox }, + { to: "/hosts", label: "Hosts", icon: FiCpu }, + { to: "/alerts", label: "Alerts", icon: FiActivity }, + { to: "/notifications", label: "Notifications", icon: FiBell }, + { to: "/integrations", label: "Integrations", icon: FiZap }, + { to: "/settings", label: "Settings", icon: FiSettings }, +]; + +function FleetStatus({ running, total }: { running: number; total: number }) { + const allGood = running === total && total > 0; + const hasIssues = running < total; + const color = allGood ? "bg-emerald-400" : hasIssues ? "bg-red-400" : "bg-slate-500"; + const label = total === 0 ? "No containers" : `${running}/${total} running`; + + return ( +
+ + {label} +
+ ); +} + +export default function AppShell({ children, onLogout, authMode }: AppShellProps) { + const location = useLocation(); + const { isAuthenticated } = useAuth(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true"); + + useEffect(() => { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed)); + }, [sidebarCollapsed]); + + const { data: containers = [] } = useQuery({ + queryKey: ["containers"], + queryFn: api.containers.list, + refetchInterval: 10_000, + enabled: isAuthenticated, + }); + + const { data: versionData } = useQuery({ + queryKey: ["version"], + queryFn: api.version, + staleTime: Infinity, + retry: false, + enabled: isAuthenticated, + }); + + const total = containers.length; + const running = containers.filter((container) => container.state === "running").length; + const activePage = NAV_ITEMS.find((item) => location.pathname === item.to || location.pathname.startsWith(`${item.to}/`)); + const activeContainerId = location.pathname.match(/^\/containers\/([^/]+)$/)?.[1]; + const activeContainer = activeContainerId + ? containers.find((container) => container.docker_id === decodeURIComponent(activeContainerId)) + : undefined; + const headerEyebrow = activeContainerId ? "Containers" : "Nestview v2"; + const headerTitle = activeContainerId ? (activeContainer?.name ?? "Container") : (activePage?.label ?? "Nestview"); + + return ( +
+ + +
+
+
+
+

{headerEyebrow}

+

{headerTitle}

+
+
+ + {authMode === "password" && onLogout && ( + + )} +
+
+
+ +
{children}
+
+
+ ); +} diff --git a/frontend/src/components/ContainerCard.tsx b/frontend/src/components/ContainerCard.tsx index d6c4d78..9ed04bc 100644 --- a/frontend/src/components/ContainerCard.tsx +++ b/frontend/src/components/ContainerCard.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { Link } from "../router"; import type { Container } from "../types"; import StatusBadge from "./StatusBadge"; import MetricBar from "./MetricBar"; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 33aa518..d675d4a 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,4 +1,4 @@ -import { Link, useLocation } from "react-router-dom"; +import { Link, useLocation } from "../router"; import { useQuery } from "@tanstack/react-query"; import { api } from "../api"; import { useAuth } from "../AuthContext"; @@ -69,7 +69,7 @@ export default function Header({ onLogout, authMode }: HeaderProps) { className="text-xs font-medium px-2 py-0.5 rounded-full bg-accent/10 text-accent border border-accent/30" title={versionData.build_sha ? `SHA: ${versionData.build_sha}` : undefined} > - v{versionData.version} + {versionData.display_version} )} diff --git a/frontend/src/components/NestviewLogo.tsx b/frontend/src/components/NestviewLogo.tsx index 8fc8f05..c43b817 100644 --- a/frontend/src/components/NestviewLogo.tsx +++ b/frontend/src/components/NestviewLogo.tsx @@ -1,6 +1,15 @@ -export default function NestviewLogo() { +interface NestviewLogoProps { + compact?: boolean; +} + +export default function NestviewLogo({ compact = false }: NestviewLogoProps) { return ( - + @@ -10,10 +19,12 @@ export default function NestviewLogo() { - - nest - view - + {!compact && ( + + nest + view + + )} ); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 72f43e1..362e1a1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from "react"; import ReactDOM from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { BrowserRouter } from "react-router-dom"; +import { RouterProvider } from "./router"; import App from "./App"; import "./index.css"; @@ -19,9 +19,9 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById("root")!).render( - + - + ); diff --git a/frontend/src/pages/ContainerDetail.tsx b/frontend/src/pages/ContainerDetail.tsx index 1f8abcf..580fdca 100644 --- a/frontend/src/pages/ContainerDetail.tsx +++ b/frontend/src/pages/ContainerDetail.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from "react"; -import { useParams, Link, useNavigate } from "react-router-dom"; +import { useParams, Link, useNavigate } from "../router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { ResponsiveContainer, @@ -10,6 +10,23 @@ import { CartesianGrid, Tooltip, } from "recharts"; +import { + FiActivity, + FiAlertTriangle, + FiArrowLeft, + FiArrowUp, + FiBox, + FiChevronDown, + FiChevronUp, + FiClock, + FiCpu, + FiDatabase, + FiFileText, + FiHardDrive, + FiHeart, + FiRefreshCw, + FiWifi, +} from "react-icons/fi"; import { api } from "../api"; import { useAuth } from "../AuthContext"; import type { Container, MetricsHistoryPoint, NetworkHistoryPoint, OperationStatus } from "../types"; @@ -968,33 +985,221 @@ function MemChart({ data }: { data: MetricsHistoryPoint[] }) { function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { return ( -
- {label} - {value} +
+ {label} + {value}
); } // ── Stat tile ───────────────────────────────────────────────────────────────── -function StatTile({ label, value, highlight = false }: { label: string; value: React.ReactNode; highlight?: boolean }) { +function StatTile({ + label, + value, + subtext, + icon, + highlight = false, + wrapValue = false, +}: { + label: string; + value: React.ReactNode; + subtext?: React.ReactNode; + icon: React.ReactNode; + highlight?: boolean; + wrapValue?: boolean; +}) { return ( -
- {label} - +
+
+ {label} + {icon} +
+
{value} - +
+ {subtext &&
{subtext}
}
); } +function CompactOverview({ + container, + isRunning, + memoryPercent, + uptimeLabel, +}: { + container: Container; + isRunning: boolean; + memoryPercent: number; + uptimeLabel: string; +}) { + const items = [ + { label: "CPU", value: `${container.cpu_percent.toFixed(2)}%` }, + { + label: "Memory", + value: container.mem_limit > 0 ? `${memoryPercent.toFixed(0)}%` : formatBytes(container.mem_usage), + }, + { label: "Uptime", value: isRunning ? uptimeLabel : "Not running" }, + { label: "Restarts", value: String(container.restart_count) }, + { label: "Network", value: latestNetworkTotal(container) }, + ]; + + return ( +
+
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ ); +} + +function DetailSection({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) { + return ( +
+

+ {icon} + {title} +

+
{children}
+
+ ); +} + +function PillList({ items, empty = "None" }: { items: string[]; empty?: string }) { + if (items.length === 0) return {empty}; + return ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ); +} + +function imageParts(image: string) { + const slashIndex = image.lastIndexOf("/"); + const tagIndex = image.lastIndexOf(":"); + if (tagIndex > slashIndex) { + return { + repo: image.slice(0, tagIndex), + tag: image.slice(tagIndex + 1), + }; + } + return { repo: image, tag: "latest" }; +} + +function sourceLabel(container: Container) { + if (!container.compose_project) return "Standalone"; + return container.compose_service + ? `${container.compose_project} / ${container.compose_service}` + : container.compose_project; +} + +function restartPolicyLabel(policy: string | null) { + if (!policy || policy === "no") return "No auto-restart"; + return policy; +} + +function healthTone(status: string | null) { + if (status === "healthy") return "border-green-500/30 bg-green-500/10 text-green-300"; + if (status === "unhealthy") return "border-red-500/30 bg-red-500/10 text-red-300"; + if (status === "starting") return "border-yellow-500/30 bg-yellow-500/10 text-yellow-300"; + return "border-slate-700 bg-surface-3 text-slate-400"; +} + +function HealthBadge({ status }: { status: string | null }) { + if (!status) return null; + return ( + + + {status} + + ); +} + +function latestNetworkTotal(container: Container) { + const rx = container.net_rx_bytes ?? 0; + const tx = container.net_tx_bytes ?? 0; + return rx > 0 || tx > 0 ? `${formatBytes(rx)} in / ${formatBytes(tx)} out` : "No traffic"; +} + +function NetworkStatValue({ container }: { container: Container }) { + const rx = container.net_rx_bytes ?? 0; + const tx = container.net_tx_bytes ?? 0; + if (rx <= 0 && tx <= 0) return <>No traffic; + + return ( + + {formatBytes(rx)} in + {formatBytes(tx)} out + + ); +} + +function AttentionBand({ container }: { container: Container }) { + const items = [ + container.state !== "running" ? `Container is currently ${container.state}.` : null, + container.health_status === "unhealthy" ? "Healthcheck is failing." : null, + container.oom_killed ? "Last stop was OOM-killed." : null, + container.container_error ? `Docker reported: ${container.container_error}` : null, + container.update_available ? "A newer image is available." : null, + container.restart_count > 0 + ? `${container.restart_count} restart${container.restart_count === 1 ? "" : "s"} recorded.` + : null, + ].filter(Boolean); + + if (items.length === 0) return null; + + return ( +
+
+ + + Attention + + {items.map((item) => ( + {item} + ))} +
+
+ ); +} + // ── Page ────────────────────────────────────────────────────────────────────── +const OVERVIEW_COLLAPSED_KEY = "nestview:container-overview-collapsed"; + +function loadOverviewCollapsed() { + try { + return localStorage.getItem(OVERVIEW_COLLAPSED_KEY) === "true"; + } catch { + return false; + } +} + export default function ContainerDetail() { const { id } = useParams<{ id: string }>(); const tz = useTimezone(); const { isAuthenticated } = useAuth(); const [operationModalOpen, setOperationModalOpen] = useState(false); + const [overviewCollapsed, setOverviewCollapsed] = useState(loadOverviewCollapsed); + const logsRef = useRef(null); + + useEffect(() => { + try { + localStorage.setItem(OVERVIEW_COLLAPSED_KEY, String(overviewCollapsed)); + } catch { + // Ignore storage failures; the control still works for the current view. + } + }, [overviewCollapsed]); const { data: container, isLoading, isError } = useQuery({ queryKey: ["container", id], @@ -1044,192 +1249,226 @@ export default function ContainerDetail() { return
Loading…
; } + const imageInfo = imageParts(container.image); + const isRunning = container.state === "running"; + const uptimeLabel = container.started_at && isRunning ? formatUptime(container.started_at) : "-"; + const memoryPercent = container.mem_limit > 0 ? (container.mem_usage / container.mem_limit) * 100 : 0; + + function scrollToLogs() { + logsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + return (
- {/* Breadcrumb + title */} -
- - - - - Dashboard - -
-

{container.name}

- -
-

{container.image}

-
- - {/* Action buttons */} - - - {/* Live stats strip — only when running */} - {container.state === "running" && ( -
- - 0 - ? `${formatBytes(container.mem_usage)} / ${formatBytes(container.mem_limit)}` - : formatBytes(container.mem_usage) - } - /> - - 0} - /> +
+
+ + + Containers +
- )} - {/* Charts row */} -
- {/* CPU */} -
-
-

CPU

-
- - - CPU % - +
+
+
+

{container.name}

+ + + {container.update_available && ( + + + Image update + + )}
-
-
- -
-
- - {/* Memory */} -
-
-

Memory

-
- - - Memory - +
+ {container.short_id} + {container.image} +
+
+ {sourceLabel(container)} + {container.status} + Last seen {formatDateTime(container.last_seen, tz)}
-
- -
-
- {/* Network I/O */} -
-
-

Network I/O

-
- - - RX - - - - TX - +
+ +
+ +
-
- -
-
+
- {/* Details + Events row */} -
- {/* Details — 2/3 width */} -
-

Details

- - - {container.image.includes(":") && ( - - )} - } /> - {container.compose_project && ( - - )} - {container.ports.length > 0 && ( - + + + + ) : ( + <> +
+ } /> + - {container.ports.map((p) => ( - {p} - ))} -
+ container.mem_limit > 0 + ? `${memoryPercent.toFixed(0)}%` + : formatBytes(container.mem_usage) } - /> - )} - {container.networks.length > 0 && ( - - {container.networks.map((n) => ( - {n} - ))} -
+ subtext={ + container.mem_limit > 0 + ? `${formatBytes(container.mem_usage)} / ${formatBytes(container.mem_limit)}` + : "limit unknown" } + icon={} /> - )} - {container.volumes.length > 0 && ( - - {container.volumes.map((v) => ( -
{v}
- ))} + } /> + } highlight={container.restart_count > 0} /> + } subtext={container.networks.length > 0 ? container.networks.join(", ") : "no networks"} icon={} wrapValue /> + + + + +
+ }> + } /> + + + + {container.health_status && } />} + + {!isRunning && container.exit_code !== null && } + {!isRunning && container.finished_at && } + {container.oom_killed && } + {container.container_error && } + + + + + }> + + + {container.compose_project && } + {container.compose_service && } + + + + + + {container.last_digest_check ? formatDateTime(container.last_digest_check, tz) : "Never checked"} + {container.update_available && ( + + + Update available + + )} + + } + /> + + + }> + } /> + } /> + + + + }> + } /> + +
+ +
+ +
+ +
+
+
+

CPU

+
+ + + CPU % +
- } - /> - )} - {container.image_size != null && ( - - )} - {container.last_digest_check != null && ( - - {formatDateTime(container.last_digest_check, tz)} - {container.update_available && ( - - - - - Update available - - )} - - } - /> - )} -
+
+
+ +
+
- {/* Events — 1/3 width */} - -
+
+
+

Memory

+
+ + + Memory + +
+
+
+ +
+
+ +
+
+

Network I/O

+
+ + + RX + + + + TX + +
+
+
+ +
+
+ + + )} - {/* Logs */} -
+

Logs

diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx new file mode 100644 index 0000000..a49f42e --- /dev/null +++ b/frontend/src/pages/Containers.tsx @@ -0,0 +1,581 @@ +import { Fragment, useMemo, useState, type KeyboardEvent } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + FiArrowUp, + FiBox, + FiChevronDown, + FiChevronRight, + FiExternalLink, + FiFilter, + FiImage, + FiRefreshCw, + FiSearch, +} from "react-icons/fi"; +import { Link } from "../router"; +import { api } from "../api"; +import StatusBadge from "../components/StatusBadge"; +import Toast from "../components/Toast"; +import { useToast } from "../hooks/useToast"; +import type { Container } from "../types"; +import { formatBytes, formatUptime } from "../utils"; + +type StatusFilter = "all" | "running" | "attention" | "updates" | "stopped"; +type GroupFilter = "all" | "standalone" | string; +type SortKey = "state" | "name" | "cpu" | "memory" | "updates" | "uptime"; + +const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [ + { key: "all", label: "All" }, + { key: "running", label: "Running" }, + { key: "attention", label: "Attention" }, + { key: "updates", label: "Updates" }, + { key: "stopped", label: "Stopped" }, +]; + +const STATE_ORDER: Record = { + running: 0, + restarting: 1, + paused: 2, + exited: 3, + dead: 4, + created: 5, +}; + +function memoryPercent(container: Container) { + return container.mem_limit > 0 ? (container.mem_usage / container.mem_limit) * 100 : 0; +} + +function normalizedDateValue(value: string | null) { + if (!value) return 0; + const normalized = /[Z+]/.test(value) ? value : value + "Z"; + const timestamp = new Date(normalized).getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function sourceLabel(container: Container) { + if (!container.compose_project) return "Standalone"; + return container.compose_service + ? `${container.compose_project} / ${container.compose_service}` + : container.compose_project; +} + +function portSummary(ports: string[]) { + if (ports.length === 0) return "None"; + if (ports.length <= 2) return ports.join(", "); + return `${ports.slice(0, 2).join(", ")} +${ports.length - 2}`; +} + +function sortContainers(containers: Container[], sort: SortKey) { + return [...containers].sort((a, b) => { + if (sort === "name") return a.name.localeCompare(b.name); + if (sort === "cpu") return b.cpu_percent - a.cpu_percent; + if (sort === "memory") return memoryPercent(b) - memoryPercent(a); + if (sort === "updates") return Number(b.update_available) - Number(a.update_available); + if (sort === "uptime") return normalizedDateValue(a.started_at) - normalizedDateValue(b.started_at); + + const stateDelta = (STATE_ORDER[a.state] ?? 9) - (STATE_ORDER[b.state] ?? 9); + return stateDelta || a.name.localeCompare(b.name); + }); +} + +function ResourceBar({ value, tone = "accent" }: { value: number; tone?: "accent" | "emerald" | "amber" }) { + const clamped = Math.max(0, Math.min(value, 100)); + const color = tone === "emerald" ? "bg-emerald-400" : tone === "amber" ? "bg-amber-400" : "bg-accent"; + + return ( +
+
+
+ ); +} + +function StatTile({ label, value, subtext, tone = "default" }: { label: string; value: string | number; subtext: string; tone?: "default" | "good" | "warn" | "danger" }) { + const valueColor = tone === "good" + ? "text-emerald-300" + : tone === "warn" + ? "text-blue-300" + : tone === "danger" + ? "text-red-300" + : "text-slate-100"; + + return ( +
+

{label}

+

{value}

+

{subtext}

+
+ ); +} + +function EmptyState({ search }: { search: string }) { + return ( +
+ +

+ {search ? "No matching containers" : "No containers found"} +

+

+ {search ? "Try a different search or filter." : "Collector data will appear here once containers are discovered."} +

+
+ ); +} + +function ContainerMobileRow({ container }: { container: Container }) { + const memPct = memoryPercent(container); + const isStopped = container.state !== "running"; + + return ( + +
+
+

{container.name}

+

{container.short_id}

+
+ +
+ +
+
+

CPU

+

{container.cpu_percent.toFixed(1)}%

+
+
+

Memory

+

{memPct.toFixed(0)}%

+
+
+

Uptime

+

{container.started_at && container.state === "running" ? formatUptime(container.started_at) : "-"}

+
+
+

Ports

+

{portSummary(container.ports)}

+
+
+ +
+ {container.status} + + Detail + + +
+ + ); +} + +function ContainerTable({ containers, checkingId, onCheckUpdates }: { containers: Container[]; checkingId: string | null; onCheckUpdates: (container: Container) => void }) { + const [expandedIds, setExpandedIds] = useState>(new Set()); + + function toggleExpanded(dockerId: string) { + setExpandedIds((current) => { + const next = new Set(current); + if (next.has(dockerId)) { + next.delete(dockerId); + } else { + next.add(dockerId); + } + return next; + }); + } + + function onRowKeyDown(event: KeyboardEvent, dockerId: string) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleExpanded(dockerId); + } + } + + return ( +
+
+ + + + + + + + + + + + + + + + + + + {containers.map((container) => { + const memPct = memoryPercent(container); + const isChecking = checkingId === container.docker_id; + const isExpanded = expandedIds.has(container.docker_id); + const isStopped = container.state !== "running"; + const uptimeLabel = container.started_at && container.state === "running" + ? formatUptime(container.started_at) + : "-"; + + return ( + + toggleExpanded(container.docker_id)} + onKeyDown={(event) => onRowKeyDown(event, container.docker_id)} + tabIndex={0} + role="button" + aria-expanded={isExpanded} + > + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
ContainerStatusResourcesUptimeActions
+
+ +
+ event.stopPropagation()} + className="block truncate font-medium text-slate-100 transition-colors hover:text-accent" + > + {container.name} + +

{container.short_id}

+
+
+
+
+ + {container.update_available && ( + + + Image update + + )} +
+
+
+
+ CPU + {container.cpu_percent.toFixed(1)}% + 75 ? "amber" : "accent"} /> +
+
+ RAM + {memPct.toFixed(0)}% + 80 ? "amber" : "emerald"} /> +
+
+
+

{uptimeLabel}

+

{container.restart_count} restart{container.restart_count === 1 ? "" : "s"}

+
+
+ + event.stopPropagation()} + className="rounded-lg border border-border bg-surface-2 p-2 text-slate-400 transition-colors hover:border-accent/40 hover:text-accent" + > + + +
+
+
+
+

Ports

+

{portSummary(container.ports)}

+
+
+

Networks

+

{container.networks.length > 0 ? container.networks.join(", ") : "None"}

+
+
+

Memory

+

+ {container.mem_limit > 0 + ? `${formatBytes(container.mem_usage)} / ${formatBytes(container.mem_limit)}` + : formatBytes(container.mem_usage)} +

+
+
+

Source

+

{sourceLabel(container)}

+
+
+

Image

+

{container.image}

+
+
+

Restarts

+

{container.restart_count}

+
+
+

Image Size

+

{container.image_size !== null ? formatBytes(container.image_size) : "Unknown"}

+
+
+

Status

+

{container.status}

+
+
+
+
+
+ ); +} + +export default function Containers() { + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [groupFilter, setGroupFilter] = useState("all"); + const [sort, setSort] = useState("state"); + const [checkingId, setCheckingId] = useState(null); + const { toastState, showToast, dismissToast } = useToast(); + + const { data: containers = [], isLoading, isError } = useQuery({ + queryKey: ["containers"], + queryFn: api.containers.list, + refetchInterval: 10_000, + }); + + const checkMutation = useMutation({ + mutationFn: (container: Container) => api.containers.checkForUpdates(container.docker_id), + onMutate: (container) => setCheckingId(container.docker_id), + onSuccess: (result) => { + showToast(result.update_available ? "Update available" : "Container is current", "success"); + queryClient.invalidateQueries({ queryKey: ["containers"] }); + }, + onError: (err: Error) => showToast(err.message, "error"), + onSettled: () => setCheckingId(null), + }); + + const groups = useMemo(() => { + return Array.from(new Set(containers.map((container) => container.compose_project).filter(Boolean) as string[])) + .sort((a, b) => a.localeCompare(b)); + }, [containers]); + + const stats = useMemo(() => { + const running = containers.filter((container) => container.state === "running").length; + const updates = containers.filter((container) => container.update_available).length; + const stopped = containers.filter((container) => container.state !== "running").length; + const attention = containers.filter((container) => container.state !== "running" || container.update_available).length; + const totalCpu = containers.filter((container) => container.state === "running").reduce((sum, container) => sum + container.cpu_percent, 0); + const totalMemory = containers.reduce((sum, container) => sum + container.mem_usage, 0); + + return { + running, + attention, + updates, + stopped, + avgCpu: running > 0 ? totalCpu / running : 0, + totalMemory, + }; + }, [containers]); + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + const matchesSearch = (container: Container) => { + if (!query) return true; + return [ + container.name, + container.image, + container.state, + container.status, + container.compose_project ?? "", + container.compose_service ?? "", + container.short_id, + ...container.ports, + ...container.networks, + ].some((value) => value.toLowerCase().includes(query)); + }; + + return sortContainers( + containers.filter((container) => { + if (!matchesSearch(container)) return false; + if (statusFilter === "running" && container.state !== "running") return false; + if (statusFilter === "attention" && container.state === "running" && !container.update_available) return false; + if (statusFilter === "updates" && !container.update_available) return false; + if (statusFilter === "stopped" && container.state === "running") return false; + if (groupFilter === "standalone" && container.compose_project) return false; + if (groupFilter !== "all" && groupFilter !== "standalone" && container.compose_project !== groupFilter) return false; + return true; + }), + sort + ); + }, [containers, groupFilter, search, sort, statusFilter]); + + const filterCounts: Record = { + all: containers.length, + running: stats.running, + attention: stats.attention, + updates: stats.updates, + stopped: stats.stopped, + }; + + return ( + <> + {toastState && ( + + )} +
+
+ 0 ? "good" : "default"} /> + 0 ? "danger" : "good"} /> + 0 ? "warn" : "default"} /> + +
+ +
+
+
+ + setSearch(event.target.value)} + placeholder="Search name, image, stack, port, network" + className="h-10 w-full rounded-lg border border-border bg-surface-2 pl-9 pr-3 text-sm text-slate-200 placeholder:text-slate-600 outline-none transition-colors focus:border-accent" + /> +
+ +
+
+ {STATUS_FILTERS.map((filter) => ( + + ))} +
+ + + + +
+
+
+ + {isLoading && ( +
+ Connecting to collector... +
+ )} + + {isError && ( +
+ Unable to reach the Nestview backend. +
+ )} + + {!isLoading && !isError && filtered.length === 0 && } + + {!isLoading && !isError && filtered.length > 0 && ( + <> + checkMutation.mutate(container)} + /> +
+ {filtered.map((container) => ( + + ))} +
+ + )} +
+ + ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9efa3c6..bac398a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from "react"; +import { Link } from "../router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api"; import { useAuth } from "../AuthContext"; @@ -526,6 +527,13 @@ export default function Dashboard() { const running = containers.filter((c) => c.state === "running").length; const stopped = containers.filter((c) => c.state !== "running").length; + const updates = containers.filter((c) => c.update_available).length; + const unhealthy = containers.filter((c) => c.state !== "running"); + const avgCpu = running > 0 + ? containers.filter((c) => c.state === "running").reduce((sum, c) => sum + c.cpu_percent, 0) / running + : 0; + const totalMemUsage = containers.reduce((sum, c) => sum + c.mem_usage, 0); + const totalMemLimit = containers.reduce((sum, c) => sum + c.mem_limit, 0); const groups: Record = {}; const ungrouped: Container[] = []; @@ -537,11 +545,140 @@ export default function Dashboard() { } } + const suggestedServices = Object.entries(groups) + .map(([project, members]) => ({ + project, + members, + running: members.filter((c) => c.state === "running").length, + updates: members.filter((c) => c.update_available).length, + })) + .sort((a, b) => a.project.localeCompare(b.project)); + return ( <> -
-
+
+
+
+

Fleet

+

{running}/{containers.length}

+

containers running

+
+
+

Needs attention

+

0 ? "text-red-300" : "text-emerald-300"}`}> + {unhealthy.length} +

+

stopped or unhealthy

+
+
+

Updates

+

0 ? "text-blue-300" : "text-slate-100"}`}>{updates}

+

available image updates

+
+
+

Resource pulse

+

{avgCpu.toFixed(1)}%

+

+ avg CPU, {totalMemLimit > 0 ? `${Math.round((totalMemUsage / totalMemLimit) * 100)}%` : "unknown"} memory +

+
+
+ +
+
+
+
+
+

Dashboard

+

Fleet command center

+

+ This first v2 pass keeps existing container controls available while introducing the service-aware overview shape. +

+
+
+
+

Suggested services

+

{suggestedServices.length}

+
+
+

Ungrouped

+

{ungrouped.length}

+
+
+
+
+ +
+
+
+
+

Needs attention

+

Current exceptions

+
+ + {unhealthy.length + updates} items + +
+
+ {unhealthy.slice(0, 4).map((container) => ( + + {container.name} + {container.state} + + ))} + {updates > 0 && ( +
+ {updates} container{updates !== 1 ? "s" : ""} with image updates available. +
+ )} + {unhealthy.length === 0 && updates === 0 && ( +

+ No immediate container issues. +

+ )} +
+
+ +
+
+
+

Services preview

+

Suggested flat groups

+
+ + compose-derived + +
+
+ {suggestedServices.slice(0, 6).map((service) => ( +
+
+

{service.project}

+ {service.running}/{service.members.length} +
+
+
0 ? (service.running / service.members.length) * 100 : 0}%` }} + /> +
+ {service.updates > 0 &&

{service.updates} update{service.updates !== 1 ? "s" : ""}

} +
+ ))} + {suggestedServices.length === 0 && ( +

+ No Compose-derived service suggestions yet. +

+ )} +
+
+
+ {/* Toolbar */}
)} -
+
{/* Sidebar — recent events */} + ); } diff --git a/frontend/src/pages/PlaceholderPage.tsx b/frontend/src/pages/PlaceholderPage.tsx new file mode 100644 index 0000000..1168b96 --- /dev/null +++ b/frontend/src/pages/PlaceholderPage.tsx @@ -0,0 +1,75 @@ +import { FiArrowRight, FiBell, FiBox, FiCpu, FiLayers, FiSettings, FiZap } from "react-icons/fi"; + +interface PlaceholderPageProps { + page: "Services" | "Containers" | "Hosts" | "Alerts" | "Notifications" | "Integrations"; +} + +const PAGE_COPY: Record = { + Services: { + icon: FiLayers, + summary: "Flat, user-managed groupings that sit above containers without being locked to Docker Compose.", + items: ["Suggested services from Compose and labels", "Linked containers and URLs", "Intermediate health rollups"], + }, + Containers: { + icon: FiBox, + summary: "Technical inventory and drilldown for every discovered container.", + items: ["Dense table and saved filters", "Update and health state", "Links to logs and metrics"], + }, + Hosts: { + icon: FiCpu, + summary: "Node-level health, capacity, and Docker runtime context.", + items: ["CPU, memory, disk, and Docker status", "Hosted container inventory", "Recent host-level events"], + }, + Alerts: { + icon: FiBell, + summary: "Triage-focused inbox for active and historical issues.", + items: ["Active, resolved, acknowledged, and silenced states", "Severity filtering", "Alert detail timeline"], + }, + Notifications: { + icon: FiSettings, + summary: "Notification policy owns providers, delivery rules, quiet hours, and exceptions.", + items: ["Channels and test sends", "Default rules and quiet hours", "Container and service exceptions"], + }, + Integrations: { + icon: FiZap, + summary: "Connection health and setup for external systems Nestview talks to.", + items: ["Docker endpoints", "Auth and webhooks", "Provider status checks"], + }, +}; + +export default function PlaceholderPage({ page }: PlaceholderPageProps) { + const config = PAGE_COPY[page]; + const Icon = config.icon; + + return ( +
+
+
+
+
+ +
+
+

v2.0 section

+

{page}

+

{config.summary}

+
+
+ + Layout pass pending + + +
+
+ +
+ {config.items.map((item) => ( +
+

{item}

+

Planned for the page-specific buildout after the shell review.

+
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Services.tsx b/frontend/src/pages/Services.tsx new file mode 100644 index 0000000..4066741 --- /dev/null +++ b/frontend/src/pages/Services.tsx @@ -0,0 +1,427 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + FiArrowRight, + FiBox, + FiCheck, + FiChevronRight, + FiLayers, + FiRefreshCw, + FiRotateCcw, + FiSearch, +} from "react-icons/fi"; +import { Link } from "../router"; +import { api } from "../api"; +import StatusBadge from "../components/StatusBadge"; +import Toast from "../components/Toast"; +import { useToast } from "../hooks/useToast"; +import type { Container } from "../types"; +import { formatBytes, formatUptime } from "../utils"; + +type ServicesMode = "compose" | "blank"; + +interface ServiceGroup { + id: string; + name: string; + source: "compose"; + members: Container[]; +} + +const SETUP_MODE_KEY = "nestview:services-setup-mode"; + +function loadSetupMode(): ServicesMode | null { + try { + const stored = localStorage.getItem(SETUP_MODE_KEY); + return stored === "compose" || stored === "blank" ? stored : null; + } catch { + return null; + } +} + +function saveSetupMode(mode: ServicesMode) { + try { + localStorage.setItem(SETUP_MODE_KEY, mode); + } catch { + // The page can still run with in-memory state if storage is unavailable. + } +} + +function clearSetupMode() { + try { + localStorage.removeItem(SETUP_MODE_KEY); + } catch { + // Ignore storage failures; the chooser can still reopen in-memory. + } +} + +function runningCount(containers: Container[]) { + return containers.filter((container) => container.state === "running").length; +} + +function serviceTone(service: ServiceGroup) { + const updates = service.members.some((container) => container.update_available); + const unhealthy = service.members.some((container) => container.state !== "running" || container.health_status === "unhealthy"); + if (unhealthy) return "danger"; + if (updates) return "warn"; + return "good"; +} + +function buildComposeServices(containers: Container[]): ServiceGroup[] { + const groups = new Map(); + + for (const container of containers) { + if (!container.compose_project) continue; + const members = groups.get(container.compose_project) ?? []; + members.push(container); + groups.set(container.compose_project, members); + } + + return Array.from(groups.entries()) + .map(([project, members]) => ({ + id: project, + name: project, + source: "compose" as const, + members: [...members].sort((a, b) => (a.compose_service ?? a.name).localeCompare(b.compose_service ?? b.name)), + })) + .sort((a, b) => { + const toneOrder = { danger: 0, warn: 1, good: 2 }; + return toneOrder[serviceTone(a)] - toneOrder[serviceTone(b)] || a.name.localeCompare(b.name); + }); +} + +function StatTile({ label, value, subtext }: { label: string; value: string | number; subtext: string }) { + return ( +
+

{label}

+

{value}

+

{subtext}

+
+ ); +} + +function SetupChoice({ + composeCount, + standaloneCount, + onChoose, +}: { + composeCount: number; + standaloneCount: number; + onChoose: (mode: ServicesMode) => void; +}) { + return ( +
+
+
+

Services setup

+

Choose a starting point

+

+ Services are the operator-facing layer above containers. Start from Compose stacks for an instant layout, or keep the page blank until manual service groups are ready. +

+
+
+ + +
+
+
+ ); +} + +function ServiceCard({ + service, + isChecking, + onCheckUpdates, +}: { + service: ServiceGroup; + isChecking: boolean; + onCheckUpdates: (service: ServiceGroup) => void; +}) { + const running = runningCount(service.members); + const updates = service.members.filter((container) => container.update_available).length; + const unhealthy = service.members.filter((container) => container.state !== "running" || container.health_status === "unhealthy").length; + const totalCpu = service.members + .filter((container) => container.state === "running") + .reduce((sum, container) => sum + container.cpu_percent, 0); + const totalMemUsage = service.members.reduce((sum, container) => sum + container.mem_usage, 0); + const totalMemLimit = service.members.reduce((sum, container) => sum + container.mem_limit, 0); + const tone = serviceTone(service); + const dotColor = tone === "danger" ? "bg-red-400" : tone === "warn" ? "bg-blue-300" : "bg-emerald-400"; + const borderColor = tone === "danger" ? "border-red-500/35" : tone === "warn" ? "border-blue-500/30" : "border-border"; + + return ( +
+
+
+
+
+ +

{service.name}

+
+

Compose stack · {running}/{service.members.length} running

+
+ +
+
+ +
+
+

CPU

+

{totalCpu.toFixed(1)}%

+
+
+

Memory

+

+ {totalMemLimit > 0 ? `${Math.round((totalMemUsage / totalMemLimit) * 100)}%` : formatBytes(totalMemUsage)} +

+
+
+

Attention

+

0 ? "text-blue-300" : "text-slate-200"}`}> + {unhealthy + updates} +

+
+
+ +
+ {service.members.map((container) => ( + + + {container.compose_service ?? container.name} + + {container.started_at && container.state === "running" ? formatUptime(container.started_at) : container.status} + + + + {container.update_available && Update} + + + + + ))} +
+
+ ); +} + +function BlankState({ composeCount, onUseCompose }: { composeCount: number; onUseCompose: () => void }) { + return ( +
+ +

No services yet

+

+ Manual service groups are next. For now, you can switch back to Compose-derived services whenever you want. +

+ {composeCount > 0 && ( + + )} +
+ ); +} + +export default function Services() { + const queryClient = useQueryClient(); + const [setupMode, setSetupMode] = useState(loadSetupMode); + const [search, setSearch] = useState(""); + const [checkingProject, setCheckingProject] = useState(null); + const { toastState, showToast, dismissToast } = useToast(); + + const { data: containers = [], isLoading, isError } = useQuery({ + queryKey: ["containers"], + queryFn: api.containers.list, + refetchInterval: 10_000, + }); + + const checkUpdatesMutation = useMutation({ + mutationFn: (service: ServiceGroup) => api.stacks.checkForUpdates(service.id), + onMutate: (service) => { + setCheckingProject(service.id); + }, + onSuccess: (result) => { + showToast(`Checked ${result.checked} container${result.checked === 1 ? "" : "s"}`, "success"); + queryClient.invalidateQueries({ queryKey: ["containers"] }); + }, + onError: (error: Error) => { + showToast(error.message, "error"); + }, + onSettled: () => { + setCheckingProject(null); + }, + }); + + const composeServices = useMemo(() => buildComposeServices(containers), [containers]); + const standalone = useMemo(() => containers.filter((container) => !container.compose_project), [containers]); + const visibleServices = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return composeServices; + return composeServices.filter((service) => { + return [ + service.name, + ...service.members.flatMap((container) => [container.name, container.compose_service ?? "", container.image]), + ].some((value) => value.toLowerCase().includes(q)); + }); + }, [composeServices, search]); + + const runningServices = composeServices.filter((service) => runningCount(service.members) === service.members.length).length; + const servicesWithAttention = composeServices.filter((service) => serviceTone(service) !== "good").length; + const updateCount = containers.filter((container) => container.update_available).length; + + function chooseMode(mode: ServicesMode) { + saveSetupMode(mode); + setSetupMode(mode); + } + + if (isLoading) { + return
Loading services...
; + } + + if (isError) { + return ( +
+ Unable to load services from container data. +
+ ); + } + + return ( +
+
+
+
+

Services

+

Service groups

+

+ Operator-facing groups built from the containers Nestview already tracks. +

+
+ {setupMode && ( + + )} +
+
+ + {!setupMode ? ( + + ) : ( + <> +
+ + + + +
+ + {setupMode === "blank" ? ( + chooseMode("compose")} /> + ) : ( + <> +
+
+ + setSearch(event.target.value)} + placeholder="Search services, containers, images..." + className="w-full min-w-0 bg-transparent text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none" + /> +
+
+ + + Compose seeded + + + Containers + + +
+
+ + {visibleServices.length > 0 ? ( +
+ {visibleServices.map((service) => ( + checkUpdatesMutation.mutate(service)} + /> + ))} +
+ ) : ( +
+ +

No matching services

+

Try a different service, container, or image search.

+
+ )} + + )} + + )} + + {toastState && } +
+ ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index dae964e..33f52bc 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -192,6 +192,10 @@ function AboutTab() { Version {sysInfo.version} +
+ Build + {sysInfo.display_version} +
Build channel {resolveBuildChannel(sysInfo.build_channel)} @@ -1552,7 +1556,7 @@ export default function Settings({ authMode }: { authMode?: string }) {

Settings

{versionData && ( - Nestview v{versionData.version} + Nestview {versionData.display_version} )}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 0000000..26ad620 --- /dev/null +++ b/frontend/src/router.tsx @@ -0,0 +1,113 @@ +import { createContext, type AnchorHTMLAttributes, type MouseEvent, type ReactNode, useContext, useEffect, useMemo, useState } from "react"; + +interface RouterState { + pathname: string; + navigate: (to: string | number, options?: { replace?: boolean }) => void; +} + +const RouterContext = createContext(null); + +function currentPath() { + return window.location.pathname || "/"; +} + +export function RouterProvider({ children }: { children: ReactNode }) { + const [pathname, setPathname] = useState(currentPath); + + useEffect(() => { + const onPopState = () => setPathname(currentPath()); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + const value = useMemo(() => ({ + pathname, + navigate(to, options) { + if (typeof to === "number") { + window.history.go(to); + return; + } + + const next = to.startsWith("/") ? to : `/${to}`; + if (next === currentPath()) return; + + if (options?.replace) { + window.history.replaceState(null, "", next); + } else { + window.history.pushState(null, "", next); + } + setPathname(currentPath()); + }, + }), [pathname]); + + return {children}; +} + +function useRouter() { + const router = useContext(RouterContext); + if (!router) throw new Error("RouterProvider is required"); + return router; +} + +export function useLocation() { + return { pathname: useRouter().pathname }; +} + +export function useNavigate() { + return useRouter().navigate; +} + +interface LinkProps extends Omit, "href"> { + to: string; +} + +export function Link({ to, onClick, ...props }: LinkProps) { + const { navigate } = useRouter(); + + function handleClick(event: MouseEvent) { + onClick?.(event); + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.altKey || + event.ctrlKey || + event.shiftKey || + props.target + ) { + return; + } + + event.preventDefault(); + navigate(to); + } + + return ; +} + +interface NavLinkProps extends Omit { + className?: string | ((state: { isActive: boolean }) => string); +} + +export function NavLink({ to, className, ...props }: NavLinkProps) { + const { pathname } = useRouter(); + const isActive = pathname === to || pathname.startsWith(`${to}/`); + const resolvedClassName = typeof className === "function" ? className({ isActive }) : className; + return ; +} + +export function Redirect({ to, replace = true }: { to: string; replace?: boolean }) { + const navigate = useNavigate(); + + useEffect(() => { + navigate(to, { replace }); + }, [navigate, replace, to]); + + return null; +} + +export function useParams>() { + const { pathname } = useRouter(); + const containerMatch = pathname.match(/^\/containers\/([^/]+)$/); + return (containerMatch ? { id: decodeURIComponent(containerMatch[1]) } : {}) as Partial; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d724bf0..63aa67d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -21,8 +21,15 @@ export interface Container { update_available: boolean; image_size: number | null; last_digest_check: string | null; + last_pulled: string | null; net_rx_bytes: number | null; net_tx_bytes: number | null; + health_status: string | null; + restart_policy: string | null; + exit_code: number | null; + oom_killed: boolean; + finished_at: string | null; + container_error: string | null; } export interface ContainerLog { @@ -132,7 +139,9 @@ export interface AnalyticsStatus { export interface SystemInfo { version: string; build_channel: string; + build_label: string; build_sha: string | null; + display_version: string; uptime_seconds: number; db_size_bytes: number | null; docker_connected: boolean;