Compare commits
270 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88e53f3f8e | |||
| 45a7ce3ece | |||
| 1ee244ee39 | |||
| a807ec00af | |||
| cdf8ab18fc | |||
| 1c3cbd187b | |||
| c27cc505e1 | |||
| 5579deb433 | |||
| 145e836342 | |||
| 728fa141ed | |||
| 1ba0014d47 | |||
| 34a348cafc | |||
| 06a75dcaba | |||
| d919b46882 | |||
| 37bd7f92a4 | |||
| 539368a144 | |||
| 84326a4844 | |||
| 67c4394c2d | |||
| 6ad885044b | |||
| 6ea64bc19e | |||
| c180a68f36 | |||
| df0953a196 | |||
| 1ff48ab86d | |||
| 6f638d571c | |||
| 0420b0eb13 | |||
| f45df093e6 | |||
| 110268717b | |||
| bce67f4ea7 | |||
| 8ae3645a2f | |||
| b4b1038a87 | |||
| a75fa45585 | |||
| 4fa84fce8a | |||
| 69cc62a931 | |||
| c5987b1b6b | |||
| dac163d88c | |||
| 24969d0d3e | |||
| d136189d18 | |||
| b981c06d59 | |||
| fcc95ac23f | |||
| f371099a86 | |||
| cd621d8e32 | |||
| e98cbb0abb | |||
| a42f7224d4 | |||
| 28db32935b | |||
| 7fdc64451c | |||
| 17c1fb3057 | |||
| 0ac0a32781 | |||
| 95ba45f4d4 | |||
| 2991250adf | |||
| c17bcd8444 | |||
| a86f11a3db | |||
| 4c73b00eb2 | |||
| 6f838b76bc | |||
| cc3c56f7df | |||
| c6cf757d6b | |||
| ce3a5fc6ef | |||
| 1b7161b82b | |||
| 6db672a56e | |||
| 3e62abfc65 | |||
| 81c06ee5b3 | |||
| c1e8e9b1a4 | |||
| dc6aefd0ab | |||
| c474cd8de2 | |||
| 94ae5baefd | |||
| 555a3209dc | |||
| e60607cc25 | |||
| 876c832dea | |||
| 70a222c1b6 | |||
| 123157624c | |||
| 8f9cb6b611 | |||
| a96cc76072 | |||
| 9560de6314 | |||
| 5b215e5b4a | |||
| 50107a94f3 | |||
| 9e85410340 | |||
| 6c7af5dff6 | |||
| bedbad9759 | |||
| bd0b68d952 | |||
| 135f9468ec | |||
| f0d3652c81 | |||
| 364b4f70d1 | |||
| 1b07246d0d | |||
| 585a4c0500 | |||
| 950ca59ac8 | |||
| 6bc81f409c | |||
| 5d0735e803 | |||
| ab93e4c227 | |||
| 939a99ac22 | |||
| f32a633071 | |||
| 8aa48d26b7 | |||
| de219a388a | |||
| c296a8e50a | |||
| 0dd836131e | |||
| 9fd16a42fe | |||
| 7af1e3cfc4 | |||
| 7fb92ccebf | |||
| 63f12d60c1 | |||
| 71e1991420 | |||
| 5707b74d8d | |||
| b73874b6ff | |||
| 7dd2d6df9f | |||
| 35f0a48953 | |||
| c3ab14fa30 | |||
| 55d3348085 | |||
| 87af015ef1 | |||
| e60c5d534e | |||
| 0c0f3bb6d8 | |||
| a7fa54476d | |||
| 1c2ac48efd | |||
| 8629f594a3 | |||
| 366d535f02 | |||
| 9f67c57a43 | |||
| 83fc231c75 | |||
| 6285f03c36 | |||
| 3942a344c7 | |||
| 0cfb1a6b5d | |||
| b48cbeb4c7 | |||
| 4769297ab9 | |||
| d588a48529 | |||
| 8e27885ca1 | |||
| 07eaec344a | |||
| 78aff7523e | |||
| f21f129a48 | |||
| 3191381bbf | |||
| 0dbf647bb7 | |||
| 89d0b8d341 | |||
| a401ba677c | |||
| 9daf636fa0 | |||
| b432017c84 | |||
| 593863b28a | |||
| b594b8b37b | |||
| 80c964db3f | |||
| 2a8af8feea | |||
| 0710b56feb | |||
| 391b7fcb54 | |||
| 8514d5ea4b | |||
| 431e2c0a5f | |||
| 2f65691e15 | |||
| 2a8e5d4952 | |||
| 42f87a526d | |||
| e396f77299 | |||
| f27d414182 | |||
| f19b70d179 | |||
| a88f5b9c95 | |||
| cf56c095bf | |||
| e0cc38ecb5 | |||
| 2c7787dced | |||
| ee72a891d5 | |||
| 85649745a2 | |||
| 7a50c070e1 | |||
| 363e1e12d5 | |||
| c5d40c9deb | |||
| 7433b346fa | |||
| 4e867d1d75 | |||
| b15ed57425 | |||
| 5e9fa2ba56 | |||
| 0e1612ff55 | |||
| 5da2ae01a0 | |||
| a42e3d3050 | |||
| 4c6bbd974a | |||
| c945dfff5e | |||
| eee47fb967 | |||
| 31b8e990b9 | |||
| e87a51cbc7 | |||
| d2e70f962e | |||
| 2f30f2061f | |||
| 09acdcf868 | |||
| 54bfea16f2 | |||
| 971cf34087 | |||
| bdeaa7ccea | |||
| 8c08ae32cc | |||
| af5b4d8411 | |||
| d457edab17 | |||
| 9fcbeca264 | |||
| f275e52843 | |||
| 2c8f4fcd5c | |||
| 21d0cd9ff6 | |||
| e152de6fc8 | |||
| b425613941 | |||
| d72b2acf02 | |||
| 09fdf5fd62 | |||
| dba4880445 | |||
| e1fc23981f | |||
| a4782ecb1c | |||
| a31000d8ad | |||
| 7cbc553521 | |||
| 7b466e3980 | |||
| 949838c5f2 | |||
| 1663535beb | |||
| 9f2cd7cab2 | |||
| f2b490e2e8 | |||
| 5d7d774ca1 | |||
| 1b769bca1d | |||
| 2b3e5d6e7c | |||
| 6cffc1f9ff | |||
| 5cfb92cfc6 | |||
| ab5f824fea | |||
| e46003711b | |||
| 213d0e897c | |||
| 2112d84051 | |||
| 7ad4095ad6 | |||
| 7b82f7aa25 | |||
| 263c518b70 | |||
| e8f1373b41 | |||
| f28b8caf36 | |||
| 084148ed32 | |||
| 252b94aece | |||
| 1422cc9bc8 | |||
| fc0bb2979b | |||
| 1ca019fa48 | |||
| 51c4cd1a4e | |||
| 3169185999 | |||
| 01344e8b2d | |||
| c98db64f32 | |||
| c538dff773 | |||
| 37f6c821d1 | |||
| b1f0af75bb | |||
| 5846fe97f4 | |||
| 6e6530af31 | |||
| 0c9a281990 | |||
| 8431920d26 | |||
| 99b626eadc | |||
| 0c45efc0ca | |||
| 59da4eb9f9 | |||
| 24cb173caf | |||
| 4eae0dca2f | |||
| 31d2ba2256 | |||
| d50d6a1b71 | |||
| b8406c1252 | |||
| 512c8653a1 | |||
| ae8cf65e84 | |||
| 69a198f723 | |||
| b742ab8fef | |||
| d19707422d | |||
| 427decc83a | |||
| 9929567679 | |||
| d0078f7e9d | |||
| d69b744ae7 | |||
| 7b17a53cab | |||
| 00b5d362e7 | |||
| 0d251a09be | |||
| e0f432bc39 | |||
| 51ad3bfe5b | |||
| 0d50958134 | |||
| 1e5b32060e | |||
| b580995966 | |||
| 73e4feb2c3 | |||
| bab7fddb90 | |||
| 6e751ef92f | |||
| 23598a5af1 | |||
| 9252f5d28a | |||
| 77c676c566 | |||
| 161f43528a | |||
| 0de936b920 | |||
| a496e7f3bc | |||
| b4d79918be | |||
| fa8f12f67a | |||
| 2a5d8c77e8 | |||
| d8b8944234 | |||
| aac297ab3e | |||
| b0b54b2ebb | |||
| 12ca6c7af4 | |||
| c5a3702496 | |||
| 677997fb75 | |||
| fbf5f6908e | |||
| 01394d709a | |||
| e0d5e59ce9 | |||
| 22e35698f3 | |||
| a3bac56275 | |||
| 3306c8ce51 |
@@ -18,18 +18,11 @@ concurrency:
|
||||
jobs:
|
||||
deploy-claude-dev-dev:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
volumes:
|
||||
- /var/packages/ContainerManager/target/usr/bin/docker:/usr/bin/docker
|
||||
- /volume1/docker/claude-dev:/claude-dev-runtime
|
||||
steps:
|
||||
- name: Checkout exact commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote add origin http://gitea:3000/jimmy/nas-tools.git
|
||||
git fetch --depth 1 origin "$GITHUB_SHA"
|
||||
git checkout --detach FETCH_HEAD
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Resolve image tags
|
||||
run: |
|
||||
@@ -56,6 +49,19 @@ jobs:
|
||||
if: env.ROLLBACK_ONLY == '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DOCKER_API_VERSION=1.43
|
||||
# Check if base image exists, build if missing
|
||||
if ! docker image inspect claude-dev-base:latest >/dev/null 2>&1; then
|
||||
echo "Base image not found, building claude-dev-base:latest..."
|
||||
DOCKER_BUILDKIT=0 docker build \
|
||||
-f claude-dev/Dockerfile.base \
|
||||
-t claude-dev-base:latest \
|
||||
claude-dev/
|
||||
else
|
||||
echo "Using existing claude-dev-base:latest"
|
||||
fi
|
||||
|
||||
# Build runtime image (fast, just copies scripts)
|
||||
DOCKER_BUILDKIT=0 docker build \
|
||||
--cache-from claude-dev:latest \
|
||||
-t "$CLAUDE_DEV_IMAGE" \
|
||||
@@ -66,18 +72,21 @@ jobs:
|
||||
if: env.ROLLBACK_ONLY == '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DOCKER_API_VERSION=1.43
|
||||
docker run --rm "$CLAUDE_DEV_IMAGE" bash /opt/claude-dev/scripts/smoke-tools.sh
|
||||
|
||||
- name: Smoke test network assumptions
|
||||
if: env.ROLLBACK_ONLY == '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DOCKER_API_VERSION=1.43
|
||||
docker run --rm --network host "$CLAUDE_DEV_IMAGE" bash /opt/claude-dev/scripts/smoke-network.sh
|
||||
|
||||
- name: Smoke test auth and mount expectations
|
||||
if: env.ROLLBACK_ONLY == '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DOCKER_API_VERSION=1.43
|
||||
docker run --rm --network host \
|
||||
-e REQUIRE_MOUNTS=1 \
|
||||
-v /volume1/docker/claude-dev/claude-config:/root/.claude \
|
||||
@@ -89,10 +98,11 @@ jobs:
|
||||
- name: Record previous image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /claude-dev-runtime
|
||||
export DOCKER_API_VERSION=1.43
|
||||
mkdir -p /volume1/docker/claude-dev
|
||||
previous_image="$(docker inspect -f '{{.Config.Image}}' claude-dev 2>/dev/null || true)"
|
||||
if [ -n "$previous_image" ]; then
|
||||
printf '%s\n' "$previous_image" > /claude-dev-runtime/previous-image.txt
|
||||
printf '%s\n' "$previous_image" > /volume1/docker/claude-dev/previous-image.txt
|
||||
echo "Saved previous image: $previous_image"
|
||||
else
|
||||
echo "No existing claude-dev container found"
|
||||
@@ -101,20 +111,25 @@ jobs:
|
||||
- name: Sync compose and target tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp claude-dev/docker-compose.yml /claude-dev-runtime/docker-compose.yml
|
||||
printf '%s\n' "$CLAUDE_DEV_IMAGE_TAG" > /claude-dev-runtime/target-tag.txt
|
||||
export DOCKER_API_VERSION=1.43
|
||||
cp claude-dev/docker-compose.yml /volume1/docker/claude-dev/docker-compose.yml
|
||||
printf '%s\n' "$CLAUDE_DEV_IMAGE_TAG" > /volume1/docker/claude-dev/target-tag.txt
|
||||
|
||||
- name: Remove stale claude-dev container
|
||||
run: docker rm -f claude-dev || true
|
||||
run: |
|
||||
export DOCKER_API_VERSION=1.43
|
||||
docker rm -f claude-dev || true
|
||||
|
||||
- name: Deploy claude-dev
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CLAUDE_DEV_IMAGE_TAG="$CLAUDE_DEV_IMAGE_TAG" docker compose -f /claude-dev-runtime/docker-compose.yml up -d --pull never
|
||||
export DOCKER_API_VERSION=1.43
|
||||
CLAUDE_DEV_IMAGE_TAG="$CLAUDE_DEV_IMAGE_TAG" docker compose -f /volume1/docker/claude-dev/docker-compose.yml up -d --pull never
|
||||
|
||||
- name: Post-deploy runtime checks
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DOCKER_API_VERSION=1.43
|
||||
docker exec claude-dev bash -lc 'claude --version'
|
||||
docker exec claude-dev bash -lc 'gh --version'
|
||||
docker exec claude-dev bash -lc 'gh auth status || true'
|
||||
|
||||
@@ -13,42 +13,32 @@ concurrency:
|
||||
jobs:
|
||||
deploy-dev:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
volumes:
|
||||
- /var/packages/ContainerManager/target/usr/bin/docker:/usr/bin/docker
|
||||
- /volume1/docker/nas-dashboard:/nas-dashboard
|
||||
steps:
|
||||
- name: Checkout exact commit
|
||||
- name: Checkout repository
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote add origin http://gitea:3000/jimmy/nas-tools.git
|
||||
git fetch --depth 1 origin "$GITHUB_SHA"
|
||||
git checkout --detach FETCH_HEAD
|
||||
git clone --depth 1 http://gitea:3000/jimmy/nas-tools.git .
|
||||
git fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout ${{ github.sha }}
|
||||
- name: Warm mirror cache for base images
|
||||
run: |
|
||||
set -u
|
||||
mirror_host="127.0.0.1:5501"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl -fsS "http://${mirror_host}/v2/" >/dev/null; then
|
||||
echo "Registry mirror is healthy at ${mirror_host}"
|
||||
else
|
||||
echo "Warning: registry mirror health check failed at ${mirror_host}; continuing with normal pull path"
|
||||
fi
|
||||
else
|
||||
echo "Warning: curl is unavailable; skipping explicit mirror health check"
|
||||
fi
|
||||
mirror_host="100.78.131.124:5501"
|
||||
|
||||
prewarm_base_image() {
|
||||
image_ref="$1"
|
||||
# Skip if image already exists locally
|
||||
if docker image inspect "${image_ref}" >/dev/null 2>&1; then
|
||||
echo "Image ${image_ref} already exists locally, skipping pre-pull"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mirror_ref="${mirror_host}/library/${image_ref}"
|
||||
echo "Attempting mirror pre-pull: ${mirror_ref}"
|
||||
if docker pull "${mirror_ref}"; then
|
||||
if timeout 30 docker pull "${mirror_ref}" 2>/dev/null; then
|
||||
docker tag "${mirror_ref}" "${image_ref}"
|
||||
echo "Mirror pre-pull succeeded for ${image_ref}"
|
||||
else
|
||||
echo "Warning: mirror pre-pull failed for ${image_ref}; continuing with normal pull during build"
|
||||
echo "Mirror pre-pull failed for ${image_ref}, will pull during build"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -56,8 +46,37 @@ jobs:
|
||||
prewarm_base_image "python:3.12-slim"
|
||||
|
||||
- name: Build dev image
|
||||
run: DOCKER_BUILDKIT=0 docker build --cache-from nas-dashboard-dev:latest -t nas-dashboard-dev:latest dashboard/
|
||||
run: |
|
||||
set -e
|
||||
export DOCKER_API_VERSION=1.43
|
||||
if ! DOCKER_BUILDKIT=0 docker build --cache-from nas-dashboard-dev:latest -t nas-dashboard-dev:latest dashboard/; then
|
||||
echo "Build failed. Checking for network issues..."
|
||||
curl -I https://registry.npmmirror.com || echo "npmmirror unreachable"
|
||||
curl -I https://pypi.tuna.tsinghua.edu.cn || echo "Tsinghua PyPI unreachable"
|
||||
exit 1
|
||||
fi
|
||||
- name: Sync runtime compose file
|
||||
run: cp dashboard/docker-compose.dev.yml /nas-dashboard/docker-compose.dev.yml
|
||||
run: |
|
||||
mkdir -p /volume1/docker/nas-dashboard
|
||||
cp dashboard/docker-compose.dev.yml /volume1/docker/nas-dashboard/docker-compose.dev.yml
|
||||
- name: Deploy dev
|
||||
run: docker compose -f /nas-dashboard/docker-compose.dev.yml up -d --pull never
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DOCKER_API_VERSION=1.43
|
||||
|
||||
cd /volume1/docker/nas-dashboard
|
||||
|
||||
# Stop existing container if running
|
||||
docker compose -f docker-compose.dev.yml -p nas-dashboard-dev down || true
|
||||
|
||||
# Ensure network exists (create only if it doesn't exist)
|
||||
if ! docker network inspect nas-dashboard_internal >/dev/null 2>&1; then
|
||||
docker network create nas-dashboard_internal
|
||||
fi
|
||||
|
||||
# Deploy with compose (without --build since we already built the image)
|
||||
docker compose -f docker-compose.dev.yml -p nas-dashboard-dev up -d --pull never
|
||||
|
||||
# Manually connect to networks after container is created
|
||||
docker network connect nas-dashboard_internal nas-dashboard-dev || true
|
||||
docker network connect gitea_gitea nas-dashboard-dev || true
|
||||
|
||||
+16
-10
@@ -18,17 +18,15 @@ jobs:
|
||||
- /var/packages/ContainerManager/target/usr/bin/docker:/usr/bin/docker
|
||||
- /volume1/docker/nas-dashboard:/nas-dashboard
|
||||
steps:
|
||||
- name: Checkout exact commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote add origin http://gitea:3000/jimmy/nas-tools.git
|
||||
git fetch --depth 1 origin "$GITHUB_SHA"
|
||||
git checkout --detach FETCH_HEAD
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Warm mirror cache for base images
|
||||
run: |
|
||||
set -u
|
||||
mirror_host="127.0.0.1:5501"
|
||||
# Use host.docker.internal or NAS IP to access registry mirror from container
|
||||
mirror_host="100.78.131.124:5501"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl -fsS "http://${mirror_host}/v2/" >/dev/null; then
|
||||
@@ -44,7 +42,7 @@ jobs:
|
||||
image_ref="$1"
|
||||
mirror_ref="${mirror_host}/library/${image_ref}"
|
||||
echo "Attempting mirror pre-pull: ${mirror_ref}"
|
||||
if docker pull "${mirror_ref}"; then
|
||||
if timeout 120 docker pull "${mirror_ref}"; then
|
||||
docker tag "${mirror_ref}" "${image_ref}"
|
||||
echo "Mirror pre-pull succeeded for ${image_ref}"
|
||||
else
|
||||
@@ -56,7 +54,15 @@ jobs:
|
||||
prewarm_base_image "python:3.12-slim"
|
||||
|
||||
- name: Build
|
||||
run: DOCKER_BUILDKIT=0 docker build -t nas-dashboard:latest dashboard/
|
||||
run: |
|
||||
set -e
|
||||
export DOCKER_API_VERSION=1.43
|
||||
if ! DOCKER_BUILDKIT=0 docker build -t nas-dashboard:latest dashboard/; then
|
||||
echo "Build failed. Checking for network issues..."
|
||||
curl -I https://registry.npmmirror.com || echo "npmmirror unreachable"
|
||||
curl -I https://pypi.tuna.tsinghua.edu.cn || echo "Tsinghua PyPI unreachable"
|
||||
exit 1
|
||||
fi
|
||||
- name: Sync runtime compose file
|
||||
run: cp dashboard/docker-compose.yml /nas-dashboard/docker-compose.yml
|
||||
- name: Deploy
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend-tests:
|
||||
name: Backend Tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SECRET_KEY: test-secret-key-for-ci-environment-32chars-minimum
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
run: |
|
||||
git clone --depth 1 http://gitea:3000/jimmy/nas-tools.git .
|
||||
git fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
- name: Setup Python
|
||||
run: |
|
||||
python3 --version
|
||||
pip3 --version
|
||||
|
||||
- name: Cache Python dependencies
|
||||
id: cache-python
|
||||
run: |
|
||||
CACHE_KEY="python-$(cat dashboard/backend/requirements.txt dashboard/backend/requirements-dev.txt | md5sum | cut -d' ' -f1)"
|
||||
CACHE_DIR="/tmp/pytest-cache/$CACHE_KEY"
|
||||
PIP_CACHE_DIR="/tmp/pip-cache"
|
||||
echo "CACHE_DIR=$CACHE_DIR" >> $GITHUB_ENV
|
||||
echo "CACHE_KEY=$CACHE_KEY" >> $GITHUB_ENV
|
||||
echo "PIP_CACHE_DIR=$PIP_CACHE_DIR" >> $GITHUB_ENV
|
||||
mkdir -p "$PIP_CACHE_DIR"
|
||||
if [ -d "$CACHE_DIR" ]; then
|
||||
echo "Cache hit for $CACHE_KEY"
|
||||
echo "cache-hit=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Cache miss for $CACHE_KEY"
|
||||
echo "cache-hit=false" >> $GITHUB_OUTPUT
|
||||
mkdir -p "$CACHE_DIR"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd dashboard/backend
|
||||
if [ "${{ steps.cache-python.outputs.cache-hit }}" = "true" ]; then
|
||||
echo "Restoring venv from cache $CACHE_KEY..."
|
||||
cp -a $CACHE_DIR/venv .
|
||||
else
|
||||
echo "Installing fresh dependencies..."
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate
|
||||
# Use Tsinghua PyPI mirror for faster downloads in China
|
||||
pip install --cache-dir=$PIP_CACHE_DIR -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
pip install --cache-dir=$PIP_CACHE_DIR -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements-dev.txt
|
||||
pip install --cache-dir=$PIP_CACHE_DIR -i https://pypi.tuna.tsinghua.edu.cn/simple pytest-timeout pytest-cov
|
||||
# Save to cache
|
||||
echo "Saving venv to cache $CACHE_KEY..."
|
||||
cp -a venv $CACHE_DIR/
|
||||
fi
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
cd dashboard/backend
|
||||
. venv/bin/activate
|
||||
pytest tests/ -v --timeout=30 \
|
||||
--cov=. --cov-report=xml --cov-report=term \
|
||||
--junit-xml=test-results.xml \
|
||||
--cov-fail-under=49
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
run: |
|
||||
cd dashboard/backend
|
||||
if [ -f coverage.xml ]; then
|
||||
echo "Coverage report generated"
|
||||
cat coverage.xml
|
||||
fi
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
run: |
|
||||
cd dashboard/backend
|
||||
if [ -f test-results.xml ]; then
|
||||
echo "Test results generated"
|
||||
cat test-results.xml | head -50
|
||||
fi
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
run: |
|
||||
git clone --depth 1 http://gitea:3000/jimmy/nas-tools.git .
|
||||
git fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
run: |
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
- name: Cache Node dependencies
|
||||
id: cache-node
|
||||
run: |
|
||||
CACHE_KEY="node-$(md5sum dashboard/frontend/package-lock.json | cut -d' ' -f1)"
|
||||
CACHE_DIR="/tmp/npm-cache/$CACHE_KEY"
|
||||
echo "CACHE_DIR=$CACHE_DIR" >> $GITHUB_ENV
|
||||
echo "CACHE_KEY=$CACHE_KEY" >> $GITHUB_ENV
|
||||
if [ -d "$CACHE_DIR" ]; then
|
||||
echo "Cache hit for $CACHE_KEY"
|
||||
echo "cache-hit=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Cache miss for $CACHE_KEY"
|
||||
echo "cache-hit=false" >> $GITHUB_OUTPUT
|
||||
mkdir -p "$CACHE_DIR"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
run: |
|
||||
cd dashboard/frontend
|
||||
if [ "${{ steps.cache-node.outputs.cache-hit }}" = "true" ]; then
|
||||
echo "Restoring from cache $CACHE_KEY..."
|
||||
cp -a $CACHE_DIR/node_modules .
|
||||
else
|
||||
echo "Installing fresh dependencies..."
|
||||
npm ci
|
||||
# Save to cache
|
||||
echo "Saving to cache $CACHE_KEY..."
|
||||
cp -a node_modules $CACHE_DIR/
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=2048"
|
||||
run: |
|
||||
cd dashboard/frontend
|
||||
npm run test:coverage -- --reporter=verbose --run --pool=forks --poolOptions.forks.maxForks=2
|
||||
|
||||
test-summary:
|
||||
name: Test Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend-tests, frontend-tests]
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Check job results
|
||||
run: |
|
||||
echo "Backend tests: ${{ needs.backend-tests.result }}"
|
||||
echo "Frontend tests: ${{ needs.frontend-tests.result }}"
|
||||
|
||||
if [ "${{ needs.backend-tests.result }}" != "success" ] || [ "${{ needs.frontend-tests.result }}" != "success" ]; then
|
||||
echo "❌ Some tests failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All tests passed"
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
# Testing Guide
|
||||
|
||||
This document describes how to run and write tests for the NAS Dashboard project.
|
||||
|
||||
## Overview
|
||||
|
||||
The project has comprehensive test coverage for both backend (Python/FastAPI) and frontend (Svelte 5):
|
||||
|
||||
- **Backend**: pytest with unit and integration tests
|
||||
- **Frontend**: Vitest with unit and component tests
|
||||
- **CI**: Automated testing via Gitea Actions
|
||||
|
||||
## Backend Testing
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd dashboard/backend
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=. --cov-report=term --cov-report=html
|
||||
|
||||
# Run only unit tests
|
||||
pytest tests/unit/ -v
|
||||
|
||||
# Run only integration tests
|
||||
pytest tests/integration/ -v
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/unit/test_auth.py -v
|
||||
|
||||
# Run specific test
|
||||
pytest tests/unit/test_auth.py::TestPasswordHashing::test_hash_and_verify_password -v
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
dashboard/backend/tests/
|
||||
├── conftest.py # Shared fixtures
|
||||
├── unit/
|
||||
│ ├── test_auth.py # JWT, TOTP, password hashing
|
||||
│ ├── test_rbac.py # Role-based access control
|
||||
│ └── test_config.py # IP parsing, env validation
|
||||
└── integration/
|
||||
├── test_auth_flow.py # Login, refresh, logout flows
|
||||
├── test_docker.py # Container operations
|
||||
└── test_files.py # File operations
|
||||
```
|
||||
|
||||
### Key Fixtures
|
||||
|
||||
- `mock_config`: Mocked configuration with test values
|
||||
- `temp_auth_file`: Temporary auth.json for testing
|
||||
- `temp_rbac_file`: Temporary rbac.json for testing
|
||||
- `valid_access_token`: Valid JWT access token
|
||||
- `admin_headers`: Headers with admin authentication
|
||||
- `mock_docker_client`: Mocked Docker SDK client
|
||||
|
||||
### Writing Tests
|
||||
|
||||
Example unit test:
|
||||
|
||||
```python
|
||||
def test_hash_and_verify_password(self):
|
||||
"""Test that password hashing and verification works."""
|
||||
password = "test_password_123"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert hashed != password
|
||||
assert verify_password(password, hashed)
|
||||
```
|
||||
|
||||
Example integration test:
|
||||
|
||||
```python
|
||||
def test_login_success_no_2fa(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test successful login without 2FA."""
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["user"] == "testadmin"
|
||||
```
|
||||
|
||||
## Frontend Testing
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd dashboard/frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Run in watch mode
|
||||
npm test -- --watch
|
||||
|
||||
# Run specific test file
|
||||
npm test -- tests/unit/api.test.js
|
||||
|
||||
# Run with UI
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
dashboard/frontend/tests/
|
||||
├── setup.js # Global test setup
|
||||
├── unit/
|
||||
│ └── api.test.js # API client tests
|
||||
├── components/
|
||||
│ └── Login.test.js # Login component tests
|
||||
└── integration/
|
||||
└── (future tests)
|
||||
```
|
||||
|
||||
### Mocked APIs
|
||||
|
||||
The test setup (`tests/setup.js`) provides mocks for:
|
||||
|
||||
- `fetch` - HTTP requests
|
||||
- `WebSocket` - WebSocket connections
|
||||
- `SpeechRecognition` / `speechSynthesis` - Web Speech API
|
||||
- `navigator.credentials` - WebAuthn
|
||||
- `localStorage` - Local storage
|
||||
- `ResizeObserver` - Resize observer
|
||||
- `MutationObserver` - Mutation observer
|
||||
|
||||
### Writing Tests
|
||||
|
||||
Example unit test:
|
||||
|
||||
```javascript
|
||||
it('should set and get token', () => {
|
||||
setToken('test-token-123');
|
||||
expect(getToken()).toBe('test-token-123');
|
||||
});
|
||||
```
|
||||
|
||||
Example component test:
|
||||
|
||||
```javascript
|
||||
it('should render login form', () => {
|
||||
render(Login);
|
||||
|
||||
expect(screen.getByLabelText(/username/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/password/i)).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
## Coverage Goals
|
||||
|
||||
- **Backend**: 80%+ overall, 95%+ for critical paths (auth, RBAC)
|
||||
- **Frontend**: 70%+ overall, 80%+ for critical components
|
||||
|
||||
## CI Integration
|
||||
|
||||
Tests run automatically on:
|
||||
- Push to `main` branch
|
||||
- Pull requests to `main`
|
||||
|
||||
The CI workflow (`.gitea/workflows/test.yml`) runs:
|
||||
1. Backend unit tests
|
||||
2. Backend integration tests
|
||||
3. Frontend tests with coverage
|
||||
4. Uploads coverage reports as artifacts
|
||||
|
||||
### Viewing CI Results
|
||||
|
||||
1. Go to the Gitea repository
|
||||
2. Click on "Actions" tab
|
||||
3. Select the workflow run
|
||||
4. Download coverage artifacts
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Backend Tests
|
||||
|
||||
**Issue**: `ModuleNotFoundError: No module named 'fastapi'`
|
||||
**Solution**: Install dependencies: `pip install -r requirements.txt -r requirements-dev.txt`
|
||||
|
||||
**Issue**: `RuntimeError: SECRET_KEY must be set`
|
||||
**Solution**: Tests use mocked config, ensure `conftest.py` is loaded
|
||||
|
||||
**Issue**: Permission denied on temp files
|
||||
**Solution**: Check that `/tmp` is writable
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
**Issue**: `Cannot find module '@testing-library/svelte'`
|
||||
**Solution**: Install dependencies: `npm install`
|
||||
|
||||
**Issue**: `ReferenceError: WebSocket is not defined`
|
||||
**Solution**: Ensure `tests/setup.js` is loaded (check `vitest.config.js`)
|
||||
|
||||
**Issue**: Component tests fail with Svelte 5 runes
|
||||
**Solution**: Use `@testing-library/svelte` v5.2.3+ which supports Svelte 5
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Use fixtures**: Leverage shared fixtures from `conftest.py`
|
||||
2. **Mock external services**: Always mock Docker, SSH, HTTP clients
|
||||
3. **Test edge cases**: Invalid inputs, expired tokens, permission errors
|
||||
4. **Atomic tests**: Each test should be independent
|
||||
5. **Clear test names**: Use descriptive names that explain what's being tested
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **Test behavior, not implementation**: Focus on user interactions
|
||||
2. **Mock API calls**: Use `vi.fn()` to mock fetch responses
|
||||
3. **Test accessibility**: Use `getByRole`, `getByLabelText` from testing-library
|
||||
4. **Avoid testing internal state**: Test what the user sees
|
||||
5. **Keep tests simple**: One assertion per test when possible
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Run with verbose output
|
||||
pytest -vv
|
||||
|
||||
# Run with print statements visible
|
||||
pytest -s
|
||||
|
||||
# Run with debugger on failure
|
||||
pytest --pdb
|
||||
|
||||
# Run specific test with full output
|
||||
pytest tests/unit/test_auth.py::TestPasswordHashing -vv -s
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
# Run with verbose output
|
||||
npm test -- --reporter=verbose
|
||||
|
||||
# Run in UI mode for debugging
|
||||
npm run test:ui
|
||||
|
||||
# Run single test file in watch mode
|
||||
npm test -- tests/unit/api.test.js --watch
|
||||
```
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
### Backend
|
||||
|
||||
1. Create test file in appropriate directory (`tests/unit/` or `tests/integration/`)
|
||||
2. Import necessary fixtures from `conftest.py`
|
||||
3. Write test classes and methods
|
||||
4. Run tests to verify
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Create test file in appropriate directory (`tests/unit/`, `tests/components/`, or `tests/integration/`)
|
||||
2. Import necessary mocks and utilities
|
||||
3. Write test cases using `describe` and `it`
|
||||
4. Run tests to verify
|
||||
|
||||
## Resources
|
||||
|
||||
- [pytest documentation](https://docs.pytest.org/)
|
||||
- [Vitest documentation](https://vitest.dev/)
|
||||
- [Testing Library documentation](https://testing-library.com/)
|
||||
- [FastAPI testing guide](https://fastapi.tiangolo.com/tutorial/testing/)
|
||||
- [Svelte testing guide](https://svelte.dev/docs/testing)
|
||||
# Testing Infrastructure
|
||||
@@ -0,0 +1,503 @@
|
||||
# Testing Infrastructure Implementation Plan
|
||||
|
||||
## Overview
|
||||
Add comprehensive unit and integration testing for the NAS Dashboard backend (Python/FastAPI) and frontend (Svelte 5). Currently, the codebase has zero test coverage and relies only on smoke tests and manual verification.
|
||||
|
||||
## Backend Testing Strategy
|
||||
|
||||
### 1. Testing Framework Setup
|
||||
- **Framework**: pytest 8.x with pytest-asyncio for async tests
|
||||
- **Coverage**: pytest-cov for coverage reporting
|
||||
- **Mocking**: pytest-mock + unittest.mock
|
||||
- **HTTP Testing**: httpx for TestClient (FastAPI built-in)
|
||||
- **Fixtures**: Centralized fixtures for common test data
|
||||
|
||||
### 2. Test Structure
|
||||
```
|
||||
dashboard/backend/
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Shared fixtures
|
||||
│ ├── unit/
|
||||
│ │ ├── test_auth.py # JWT, TOTP, password hashing
|
||||
│ │ ├── test_rbac.py # Permission checks, role resolution
|
||||
│ │ ├── test_config.py # IP parsing, env validation
|
||||
│ │ └── test_utils.py # Path validation, encryption
|
||||
│ └── integration/
|
||||
│ ├── test_auth_flow.py # Login, refresh, logout flows
|
||||
│ ├── test_docker.py # Container operations (mocked)
|
||||
│ ├── test_files.py # File operations with permissions
|
||||
│ ├── test_terminal.py # WebSocket terminal (mocked SSH)
|
||||
│ ├── test_passkey.py # WebAuthn flows
|
||||
│ └── test_security.py # Rate limiting, audit logging
|
||||
```
|
||||
|
||||
### 3. Key Test Areas
|
||||
|
||||
#### Unit Tests (High Priority)
|
||||
1. **auth.py** (~300 lines to test)
|
||||
- JWT token generation/validation
|
||||
- Token expiry and refresh logic
|
||||
- TOTP generation/verification
|
||||
- Password hashing/verification
|
||||
- Token version management
|
||||
- Encryption/decryption (Fernet + legacy fallback)
|
||||
|
||||
2. **rbac.py** (~200 lines to test)
|
||||
- Role resolution (admin/member/viewer)
|
||||
- Page access checks
|
||||
- Sidebar link filtering
|
||||
- User override logic
|
||||
- Atomic JSON persistence
|
||||
|
||||
3. **config.py** (~150 lines to test)
|
||||
- IP range parsing (LAN, Tailscale, trusted proxies)
|
||||
- Environment variable validation
|
||||
- Default value handling
|
||||
|
||||
4. **Path validation utilities**
|
||||
- Traversal protection
|
||||
- Symlink resolution
|
||||
- Blocked directory checks
|
||||
|
||||
#### Integration Tests (High Priority)
|
||||
1. **Authentication Flow** (routers/auth.py - 316 lines)
|
||||
- POST /api/auth/login → TOTP required → token issued
|
||||
- POST /api/auth/refresh → new tokens
|
||||
- POST /api/auth/logout → token revoked
|
||||
- Proxy auth with Remote-User header
|
||||
- Rate limiting enforcement
|
||||
|
||||
2. **Docker Operations** (routers/docker_router.py - 37 lines)
|
||||
- GET /api/docker/containers → list with health status
|
||||
- POST /api/docker/containers/{id}/start → admin only
|
||||
- GET /api/docker/containers/{id}/logs → tail limit
|
||||
|
||||
3. **File Operations** (routers/files.py - 128 lines)
|
||||
- GET /api/files/browse → directory listing
|
||||
- POST /api/files/upload → 100MB limit, admin only
|
||||
- DELETE /api/files/delete → permission checks
|
||||
|
||||
4. **Terminal WebSocket** (routers/terminal.py - 220 lines)
|
||||
- WebSocket /ws/terminal?host=nas → auth validation
|
||||
- Session reservation (max 5 global, 5 per user)
|
||||
- SSH connection lifecycle (mocked)
|
||||
- Keepalive ping/pong
|
||||
|
||||
5. **Passkey/WebAuthn** (routers/passkey.py - 188 lines)
|
||||
- Registration challenge generation
|
||||
- Credential verification
|
||||
- Credential management
|
||||
|
||||
### 4. Mocking Strategy
|
||||
|
||||
**Docker SDK** (docker.DockerClient):
|
||||
```python
|
||||
@pytest.fixture
|
||||
def mock_docker_client(mocker):
|
||||
client = mocker.Mock()
|
||||
client.containers.list.return_value = [
|
||||
mocker.Mock(id="abc123", name="test-container", status="running")
|
||||
]
|
||||
return client
|
||||
```
|
||||
|
||||
**asyncssh** (SSH connections):
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def mock_ssh_connection(mocker):
|
||||
conn = mocker.AsyncMock()
|
||||
conn.create_process.return_value = mocker.AsyncMock()
|
||||
mocker.patch('asyncssh.connect', return_value=conn)
|
||||
return conn
|
||||
```
|
||||
|
||||
**httpx** (External API calls):
|
||||
```python
|
||||
@pytest.fixture
|
||||
def mock_httpx_client(mocker):
|
||||
client = mocker.AsyncMock()
|
||||
mocker.patch('httpx.AsyncClient', return_value=client)
|
||||
return client
|
||||
```
|
||||
|
||||
### 5. Test Fixtures (conftest.py)
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def test_app():
|
||||
"""FastAPI test client with overridden dependencies"""
|
||||
from main import app
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def valid_jwt_token():
|
||||
"""Generate valid JWT for testing"""
|
||||
return create_access_token({"sub": "testuser", "role": "admin"})
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rbac_data():
|
||||
"""Sample RBAC configuration"""
|
||||
return {
|
||||
"role_defaults": {"admin": {"pages": "*", "sidebar_links": "*"}},
|
||||
"user_overrides": {}
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def temp_auth_file(tmp_path):
|
||||
"""Temporary auth.json for testing"""
|
||||
auth_file = tmp_path / "auth.json"
|
||||
auth_file.write_text('{"totp_secrets": {}, "passkey_credentials": {}}')
|
||||
return auth_file
|
||||
```
|
||||
|
||||
### 6. Coverage Goals
|
||||
- **Target**: 80%+ overall coverage
|
||||
- **Critical paths**: 95%+ (auth, RBAC, path validation)
|
||||
- **Routers**: 70%+ (focus on happy path + error cases)
|
||||
|
||||
### 7. CI Integration
|
||||
Add to `.gitea/workflows/test.yml`:
|
||||
```yaml
|
||||
name: Backend Tests
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: nas-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Run pytest
|
||||
run: |
|
||||
cd dashboard/backend
|
||||
pip install -r requirements.txt -r requirements-dev.txt
|
||||
pytest --cov=. --cov-report=term --cov-report=html
|
||||
- name: Upload coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-report
|
||||
path: dashboard/backend/htmlcov/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Testing Strategy
|
||||
|
||||
### 1. Testing Framework Setup
|
||||
- **Framework**: Vitest 2.x (Vite-native, fast)
|
||||
- **Component Testing**: @testing-library/svelte for Svelte 5
|
||||
- **Mocking**: vi.mock() for modules, vi.fn() for functions
|
||||
- **Coverage**: @vitest/coverage-v8
|
||||
|
||||
### 2. Test Structure
|
||||
```
|
||||
dashboard/frontend/
|
||||
├── tests/
|
||||
│ ├── setup.js # Global test setup
|
||||
│ ├── unit/
|
||||
│ │ ├── api.test.js # API client, token management
|
||||
│ │ └── voice.test.js # VoiceSession class
|
||||
│ ├── components/
|
||||
│ │ ├── Sidebar.test.js # Navigation, drag-drop, RBAC
|
||||
│ │ └── App.test.js # Routing, auth flow
|
||||
│ └── integration/
|
||||
│ ├── Login.test.js # Passkey + password flows
|
||||
│ ├── Terminal.test.js # WebSocket, reconnection
|
||||
│ ├── Docker.test.js # Container management
|
||||
│ └── Files.test.js # File browser operations
|
||||
```
|
||||
|
||||
### 3. Key Test Areas
|
||||
|
||||
#### Unit Tests
|
||||
1. **api.js** (~200 lines)
|
||||
- Token storage/retrieval
|
||||
- Auto-refresh on 401
|
||||
- Error handling
|
||||
- Request methods (get, post, put, del)
|
||||
|
||||
2. **voice.js** (~150 lines)
|
||||
- VoiceSession state management
|
||||
- STT/TTS integration (mocked)
|
||||
- Language switching
|
||||
- Talk mode handling
|
||||
|
||||
#### Component Tests
|
||||
1. **App.svelte** (~400 lines)
|
||||
- Initial auth check
|
||||
- Page routing via query params
|
||||
- Theme toggle persistence
|
||||
- User info loading
|
||||
|
||||
2. **Sidebar.svelte** (~800 lines)
|
||||
- Category rendering
|
||||
- Drag-drop reordering
|
||||
- Role-based link filtering
|
||||
- Collapse state persistence
|
||||
|
||||
3. **Login.svelte** (~230 lines)
|
||||
- Passkey authentication flow
|
||||
- Password + TOTP flow
|
||||
- Error message display
|
||||
- Form validation
|
||||
|
||||
4. **Terminal.svelte** (~652 lines)
|
||||
- WebSocket connection lifecycle
|
||||
- Tab management
|
||||
- Reconnection with exponential backoff
|
||||
- Auth failure recovery
|
||||
- Image upload (drag-drop/paste)
|
||||
|
||||
5. **Docker.svelte** (~133 lines)
|
||||
- Container list rendering
|
||||
- Start/stop/restart actions
|
||||
- Log modal display
|
||||
|
||||
6. **Files.svelte** (~151 lines)
|
||||
- Directory navigation
|
||||
- File upload
|
||||
- Download/delete actions
|
||||
|
||||
### 4. Mocking Strategy
|
||||
|
||||
**Fetch API**:
|
||||
```javascript
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
test('login success', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ access_token: 'test-token' })
|
||||
});
|
||||
// test logic
|
||||
});
|
||||
```
|
||||
|
||||
**WebSocket**:
|
||||
```javascript
|
||||
class MockWebSocket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = WebSocket.CONNECTING;
|
||||
setTimeout(() => {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}, 0);
|
||||
}
|
||||
send(data) { /* mock */ }
|
||||
close() { /* mock */ }
|
||||
}
|
||||
global.WebSocket = MockWebSocket;
|
||||
```
|
||||
|
||||
**Web Speech API**:
|
||||
```javascript
|
||||
global.SpeechRecognition = vi.fn(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
addEventListener: vi.fn()
|
||||
}));
|
||||
|
||||
global.speechSynthesis = {
|
||||
speak: vi.fn(),
|
||||
cancel: vi.fn()
|
||||
};
|
||||
```
|
||||
|
||||
**xterm.js**:
|
||||
```javascript
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: vi.fn(() => ({
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}))
|
||||
}));
|
||||
```
|
||||
|
||||
### 5. Test Configuration (vitest.config.js)
|
||||
|
||||
```javascript
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
exclude: ['node_modules/', 'tests/', 'dist/']
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Coverage Goals
|
||||
- **Target**: 70%+ overall coverage
|
||||
- **Critical components**: 80%+ (Terminal, Login, App)
|
||||
- **API client**: 90%+ (core infrastructure)
|
||||
|
||||
### 7. CI Integration
|
||||
Add to `.gitea/workflows/test.yml`:
|
||||
```yaml
|
||||
frontend-tests:
|
||||
runs-on: nas-runner
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd dashboard/frontend
|
||||
npm ci
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd dashboard/frontend
|
||||
npm run test -- --coverage
|
||||
- name: Upload coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: dashboard/frontend/coverage/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Backend Foundation (Priority: High)
|
||||
1. Install pytest, pytest-asyncio, pytest-cov, pytest-mock
|
||||
2. Create test directory structure
|
||||
3. Write conftest.py with shared fixtures
|
||||
4. Implement unit tests for auth.py (JWT, TOTP, encryption)
|
||||
5. Implement unit tests for rbac.py (permissions, role resolution)
|
||||
6. Implement unit tests for config.py (IP parsing)
|
||||
|
||||
**Estimated effort**: 4-6 hours
|
||||
**Files created**: 6-8 test files, conftest.py, requirements-dev.txt
|
||||
|
||||
### Phase 2: Backend Integration Tests (Priority: High)
|
||||
1. Mock Docker SDK for container tests
|
||||
2. Mock asyncssh for terminal tests
|
||||
3. Mock httpx for external API tests
|
||||
4. Implement integration tests for auth flow (login, refresh, logout)
|
||||
5. Implement integration tests for Docker operations
|
||||
6. Implement integration tests for file operations
|
||||
7. Implement integration tests for terminal WebSocket
|
||||
|
||||
**Estimated effort**: 6-8 hours
|
||||
**Files created**: 7-10 integration test files
|
||||
|
||||
### Phase 3: Frontend Foundation (Priority: Medium)
|
||||
1. Install vitest, @testing-library/svelte, @vitest/coverage-v8, jsdom
|
||||
2. Create vitest.config.js
|
||||
3. Create test directory structure and setup.js
|
||||
4. Implement unit tests for api.js (token management, auto-refresh)
|
||||
5. Implement unit tests for voice.js (VoiceSession)
|
||||
|
||||
**Estimated effort**: 3-4 hours
|
||||
**Files created**: vitest.config.js, setup.js, 2-3 test files
|
||||
|
||||
### Phase 4: Frontend Component Tests (Priority: Medium)
|
||||
1. Mock fetch, WebSocket, Web Speech API, xterm.js
|
||||
2. Implement component tests for App.svelte (routing, auth)
|
||||
3. Implement component tests for Login.svelte (passkey, password flows)
|
||||
4. Implement component tests for Sidebar.svelte (drag-drop, RBAC)
|
||||
5. Implement component tests for Terminal.svelte (WebSocket, reconnection)
|
||||
6. Implement component tests for Docker.svelte (actions, logs)
|
||||
7. Implement component tests for Files.svelte (browse, upload)
|
||||
|
||||
**Estimated effort**: 8-10 hours
|
||||
**Files created**: 6-8 component test files
|
||||
|
||||
### Phase 5: CI Integration (Priority: Medium)
|
||||
1. Create .gitea/workflows/test.yml
|
||||
2. Configure pytest in CI with coverage reporting
|
||||
3. Configure vitest in CI with coverage reporting
|
||||
4. Add coverage badges to README (optional)
|
||||
5. Configure coverage thresholds (fail if below target)
|
||||
|
||||
**Estimated effort**: 2-3 hours
|
||||
**Files created**: 1 workflow file
|
||||
|
||||
### Phase 6: Documentation & Maintenance (Priority: Low)
|
||||
1. Add TESTING.md with instructions for running tests
|
||||
2. Document mocking patterns and fixtures
|
||||
3. Add pre-commit hook for running tests (optional)
|
||||
4. Set up coverage monitoring
|
||||
|
||||
**Estimated effort**: 1-2 hours
|
||||
**Files created**: TESTING.md
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
### Backend (requirements-dev.txt)
|
||||
```
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.24.0
|
||||
pytest-cov==6.0.0
|
||||
pytest-mock==3.14.0
|
||||
```
|
||||
|
||||
### Frontend (package.json devDependencies)
|
||||
```json
|
||||
{
|
||||
"vitest": "^2.1.8",
|
||||
"@testing-library/svelte": "^5.2.3",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"jsdom": "^25.0.1"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **Backend**: 80%+ test coverage with all critical paths tested
|
||||
2. **Frontend**: 70%+ test coverage with key components tested
|
||||
3. **CI**: Tests run automatically on push/PR with coverage reports
|
||||
4. **Documentation**: Clear instructions for running and writing tests
|
||||
5. **Maintainability**: Fixtures and mocks are reusable and well-documented
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
**Risk**: Tests may be slow due to Docker/SSH mocking complexity
|
||||
**Mitigation**: Use lightweight mocks, avoid actual Docker/SSH connections
|
||||
|
||||
**Risk**: Frontend tests may be brittle due to Svelte 5 runes
|
||||
**Mitigation**: Use @testing-library/svelte best practices, test behavior not implementation
|
||||
|
||||
**Risk**: CI runner may lack resources for parallel test execution
|
||||
**Mitigation**: Run tests sequentially if needed, optimize test fixtures
|
||||
|
||||
**Risk**: Existing code may need refactoring for testability
|
||||
**Mitigation**: Minimal refactoring, focus on testing current behavior first
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should we add E2E tests with Playwright in addition to unit/integration tests?
|
||||
- **Recommendation**: Start with unit/integration, add E2E later if needed
|
||||
|
||||
2. Should we enforce coverage thresholds in CI (fail build if below target)?
|
||||
- **Recommendation**: Yes, but start with lenient thresholds (60%) and increase gradually
|
||||
|
||||
3. Should we test all 13 routers or focus on high-risk areas first?
|
||||
- **Recommendation**: Focus on auth, docker, terminal, files first (80% of risk)
|
||||
|
||||
4. Should we mock the file system or use temporary directories?
|
||||
- **Recommendation**: Use pytest's tmp_path fixture for real file operations
|
||||
|
||||
5. Should we test WebSocket reconnection logic in detail?
|
||||
- **Recommendation**: Yes, it's complex and critical (Terminal.svelte has 652 lines)
|
||||
@@ -0,0 +1,6 @@
|
||||
ANTHROPIC_API_KEY=your_api_key_here
|
||||
ANTHROPIC_BASE_URL=https://www.bytecatcode.org
|
||||
SUMMARY_HOUR=23
|
||||
COLLECT_INTERVAL=60
|
||||
DB_PATH=/app/data/conversations.db
|
||||
CONVERSATIONS_PATH=/app/conversations
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY *.py ./
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Run as non-root user
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,130 @@
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
|
||||
import db
|
||||
import parser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONVERSATIONS_PATH = os.environ.get("CONVERSATIONS_PATH", "/app/conversations")
|
||||
COLLECT_INTERVAL = int(os.environ.get("COLLECT_INTERVAL", "60"))
|
||||
|
||||
|
||||
async def discover_jsonl_files():
|
||||
"""Discover all .jsonl files in conversations directory"""
|
||||
conversations_dir = Path(CONVERSATIONS_PATH)
|
||||
if not conversations_dir.exists():
|
||||
logger.warning(f"Conversations directory does not exist: {CONVERSATIONS_PATH}")
|
||||
return []
|
||||
|
||||
jsonl_files = []
|
||||
for file_path in conversations_dir.rglob("*.jsonl"):
|
||||
if file_path.is_file():
|
||||
jsonl_files.append(str(file_path))
|
||||
|
||||
return jsonl_files
|
||||
|
||||
|
||||
async def process_file(file_path: str):
|
||||
"""Process a single conversation file incrementally"""
|
||||
try:
|
||||
# Get checkpoint
|
||||
checkpoint = await db.get_checkpoint(file_path)
|
||||
start_line = checkpoint['last_line_count'] if checkpoint else 0
|
||||
|
||||
# Get file modification time
|
||||
file_stat = os.stat(file_path)
|
||||
file_mtime = datetime.fromtimestamp(file_stat.st_mtime).isoformat()
|
||||
|
||||
# Check if file has been modified
|
||||
if checkpoint and checkpoint['last_modified'] == file_mtime:
|
||||
# File hasn't changed, skip
|
||||
return 0
|
||||
|
||||
# Count total lines
|
||||
with open(file_path, 'r') as f:
|
||||
total_lines = sum(1 for _ in f)
|
||||
|
||||
if total_lines <= start_line:
|
||||
# No new lines
|
||||
return 0
|
||||
|
||||
# Process in batches for large files (max 200 lines at a time)
|
||||
batch_size = 200
|
||||
end_line = min(start_line + batch_size, total_lines)
|
||||
|
||||
# Parse new content
|
||||
logger.info(f"Processing {file_path} from line {start_line} to {end_line} (total: {total_lines})")
|
||||
conversation_data = parser.parse_conversation_file(file_path, start_line, end_line)
|
||||
|
||||
if not conversation_data:
|
||||
logger.warning(f"No data extracted from {file_path}")
|
||||
return 0
|
||||
|
||||
# Store in database
|
||||
await db.upsert_conversation(
|
||||
conversation_data['session_id'],
|
||||
{
|
||||
'project_path': conversation_data['project_path'],
|
||||
'git_branch': conversation_data.get('git_branch'),
|
||||
'started_at': conversation_data['started_at'],
|
||||
'last_updated_at': conversation_data['last_updated_at'],
|
||||
'message_count': conversation_data['message_count'],
|
||||
'user_message_count': conversation_data['user_message_count'],
|
||||
'assistant_message_count': conversation_data['assistant_message_count'],
|
||||
'total_input_tokens': conversation_data['total_input_tokens'],
|
||||
'total_output_tokens': conversation_data['total_output_tokens'],
|
||||
'model': conversation_data.get('model'),
|
||||
'slug': conversation_data.get('slug'),
|
||||
'file_path': file_path
|
||||
}
|
||||
)
|
||||
|
||||
# Store messages
|
||||
for msg in conversation_data['messages']:
|
||||
await db.insert_message(msg)
|
||||
|
||||
# Store tool calls
|
||||
for tool_call in conversation_data['tool_calls']:
|
||||
await db.insert_tool_call(tool_call)
|
||||
|
||||
# Update checkpoint (use end_line instead of total_lines for batch processing)
|
||||
await db.update_checkpoint(file_path, end_line, file_mtime)
|
||||
|
||||
logger.info(f"Processed {len(conversation_data['messages'])} messages from {file_path} (batch {start_line}-{end_line}/{total_lines})")
|
||||
return len(conversation_data['messages'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {file_path}: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
async def collect_conversations():
|
||||
"""Main collection loop"""
|
||||
while True:
|
||||
try:
|
||||
logger.info("Starting conversation collection cycle")
|
||||
|
||||
# Discover files
|
||||
files = await discover_jsonl_files()
|
||||
logger.info(f"Found {len(files)} conversation files")
|
||||
|
||||
# Process each file
|
||||
total_messages = 0
|
||||
for file_path in files:
|
||||
messages_processed = await process_file(file_path)
|
||||
total_messages += messages_processed
|
||||
|
||||
if total_messages > 0:
|
||||
logger.info(f"Collection cycle complete: processed {total_messages} new messages")
|
||||
else:
|
||||
logger.debug("Collection cycle complete: no new messages")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in collection cycle: {e}", exc_info=True)
|
||||
|
||||
# Wait before next cycle
|
||||
await asyncio.sleep(COLLECT_INTERVAL)
|
||||
@@ -0,0 +1,215 @@
|
||||
import os
|
||||
import aiosqlite
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/conversations.db")
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database with schema"""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
# Conversations table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT UNIQUE NOT NULL,
|
||||
project_path TEXT NOT NULL,
|
||||
git_branch TEXT,
|
||||
started_at DATETIME NOT NULL,
|
||||
last_updated_at DATETIME NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
user_message_count INTEGER DEFAULT 0,
|
||||
assistant_message_count INTEGER DEFAULT 0,
|
||||
total_input_tokens INTEGER DEFAULT 0,
|
||||
total_output_tokens INTEGER DEFAULT 0,
|
||||
model TEXT,
|
||||
slug TEXT,
|
||||
file_path TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Messages table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
message_uuid TEXT UNIQUE NOT NULL,
|
||||
parent_uuid TEXT,
|
||||
role TEXT NOT NULL,
|
||||
content_text TEXT,
|
||||
timestamp DATETIME NOT NULL,
|
||||
prompt_id TEXT,
|
||||
model TEXT,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
has_tool_use BOOLEAN DEFAULT 0,
|
||||
has_thinking BOOLEAN DEFAULT 0,
|
||||
stop_reason TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Tool calls table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_uuid TEXT NOT NULL,
|
||||
tool_use_id TEXT UNIQUE NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
tool_input TEXT,
|
||||
tool_result TEXT,
|
||||
is_error BOOLEAN DEFAULT 0,
|
||||
timestamp DATETIME NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Daily summaries table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS daily_summaries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT UNIQUE NOT NULL,
|
||||
project_path TEXT,
|
||||
conversation_count INTEGER,
|
||||
total_messages INTEGER,
|
||||
total_tokens INTEGER,
|
||||
summary_content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Checkpoints table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
file_path TEXT PRIMARY KEY,
|
||||
last_line_count INTEGER NOT NULL,
|
||||
last_modified DATETIME NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_tool_calls_message ON tool_calls(message_uuid)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_conversations_project ON conversations(project_path)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_conversations_started ON conversations(started_at)")
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def upsert_conversation(session_id, data):
|
||||
"""Insert or update conversation"""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("""
|
||||
INSERT INTO conversations (
|
||||
session_id, project_path, git_branch, started_at, last_updated_at,
|
||||
message_count, user_message_count, assistant_message_count,
|
||||
total_input_tokens, total_output_tokens, model, slug, file_path
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
last_updated_at = excluded.last_updated_at,
|
||||
message_count = excluded.message_count,
|
||||
user_message_count = excluded.user_message_count,
|
||||
assistant_message_count = excluded.assistant_message_count,
|
||||
total_input_tokens = excluded.total_input_tokens,
|
||||
total_output_tokens = excluded.total_output_tokens,
|
||||
model = excluded.model,
|
||||
slug = excluded.slug,
|
||||
git_branch = excluded.git_branch
|
||||
""", (
|
||||
session_id, data['project_path'], data.get('git_branch'),
|
||||
data['started_at'], data['last_updated_at'],
|
||||
data['message_count'], data['user_message_count'], data['assistant_message_count'],
|
||||
data['total_input_tokens'], data['total_output_tokens'],
|
||||
data.get('model'), data.get('slug'), data['file_path']
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def insert_message(message_data):
|
||||
"""Insert message (skip if exists)"""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
try:
|
||||
await db.execute("""
|
||||
INSERT INTO messages (
|
||||
session_id, message_uuid, parent_uuid, role, content_text,
|
||||
timestamp, prompt_id, model, input_tokens, output_tokens,
|
||||
has_tool_use, has_thinking, stop_reason
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
message_data['session_id'], message_data['message_uuid'],
|
||||
message_data.get('parent_uuid'), message_data['role'],
|
||||
message_data.get('content_text'), message_data['timestamp'],
|
||||
message_data.get('prompt_id'), message_data.get('model'),
|
||||
message_data.get('input_tokens'), message_data.get('output_tokens'),
|
||||
message_data.get('has_tool_use', False), message_data.get('has_thinking', False),
|
||||
message_data.get('stop_reason')
|
||||
))
|
||||
await db.commit()
|
||||
except aiosqlite.IntegrityError:
|
||||
# Message already exists, skip
|
||||
pass
|
||||
|
||||
|
||||
async def insert_tool_call(tool_data):
|
||||
"""Insert tool call (skip if exists)"""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
try:
|
||||
await db.execute("""
|
||||
INSERT INTO tool_calls (
|
||||
message_uuid, tool_use_id, tool_name, tool_input,
|
||||
tool_result, is_error, timestamp
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
tool_data['message_uuid'], tool_data['tool_use_id'],
|
||||
tool_data['tool_name'], tool_data.get('tool_input'),
|
||||
tool_data.get('tool_result'), tool_data.get('is_error', False),
|
||||
tool_data['timestamp']
|
||||
))
|
||||
await db.commit()
|
||||
except aiosqlite.IntegrityError:
|
||||
# Tool call already exists, skip
|
||||
pass
|
||||
|
||||
|
||||
async def get_checkpoint(file_path):
|
||||
"""Get checkpoint for a file"""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT last_line_count, last_modified FROM checkpoints WHERE file_path = ?",
|
||||
(file_path,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return {"last_line_count": row[0], "last_modified": row[1]} if row else None
|
||||
|
||||
|
||||
async def update_checkpoint(file_path, line_count, modified_time):
|
||||
"""Update checkpoint for a file"""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("""
|
||||
INSERT INTO checkpoints (file_path, last_line_count, last_modified)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(file_path) DO UPDATE SET
|
||||
last_line_count = excluded.last_line_count,
|
||||
last_modified = excluded.last_modified
|
||||
""", (file_path, line_count, modified_time))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def upsert_summary(date, data):
|
||||
"""Insert or update daily summary"""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("""
|
||||
INSERT INTO daily_summaries (
|
||||
date, project_path, conversation_count, total_messages,
|
||||
total_tokens, summary_content
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(date) DO UPDATE SET
|
||||
project_path = excluded.project_path,
|
||||
conversation_count = excluded.conversation_count,
|
||||
total_messages = excluded.total_messages,
|
||||
total_tokens = excluded.total_tokens,
|
||||
summary_content = excluded.summary_content,
|
||||
created_at = CURRENT_TIMESTAMP
|
||||
""", (
|
||||
date, data.get('project_path'), data['conversation_count'],
|
||||
data['total_messages'], data['total_tokens'], data['summary_content']
|
||||
))
|
||||
await db.commit()
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
claude-code-tracker:
|
||||
build: .
|
||||
container_name: claude-code-tracker
|
||||
restart: unless-stopped
|
||||
network_mode: bridge
|
||||
volumes:
|
||||
- /volume1/docker/claude-code-tracker/data:/app/data
|
||||
- /volume1/docker/claude-code-tracker/conversations:/app/conversations:ro
|
||||
environment:
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-https://www.bytecatcode.org}
|
||||
- SUMMARY_HOUR=${SUMMARY_HOUR:-23}
|
||||
- COLLECT_INTERVAL=${COLLECT_INTERVAL:-60}
|
||||
- DB_PATH=/app/data/conversations.db
|
||||
- CONVERSATIONS_PATH=/app/conversations
|
||||
- TRIGGER_PATH=/app/data/trigger
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
import db
|
||||
import collector
|
||||
import summarizer
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main service loop"""
|
||||
logger.info("Starting Claude Code Tracker service")
|
||||
|
||||
# Initialize database
|
||||
await db.init_db()
|
||||
logger.info("Database initialized")
|
||||
|
||||
# Create data directory if it doesn't exist
|
||||
data_dir = os.path.dirname(db.DB_PATH)
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
# Start collection and summarization tasks
|
||||
tasks = [
|
||||
asyncio.create_task(collector.collect_conversations()),
|
||||
asyncio.create_task(summarizer.summarize_daily())
|
||||
]
|
||||
|
||||
logger.info("Service started - collection and summarization tasks running")
|
||||
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,240 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_jsonl_file(file_path: str, start_line: int = 0, end_line: int = None) -> List[Dict[str, Any]]:
|
||||
"""Parse JSONL file from a specific line number to end_line (or EOF if None)"""
|
||||
messages = []
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for i, line in enumerate(f):
|
||||
if i < start_line:
|
||||
continue
|
||||
if end_line is not None and i >= end_line:
|
||||
break
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
messages.append(obj)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse line {i} in {file_path}: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read {file_path}: {e}")
|
||||
return messages
|
||||
|
||||
|
||||
def extract_text_content(content: Any) -> str:
|
||||
"""Extract text from message content"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get('type') == 'text':
|
||||
text_parts.append(block.get('text', ''))
|
||||
elif block.get('type') == 'tool_result':
|
||||
# Include tool results in searchable text
|
||||
result = block.get('content', '')
|
||||
if isinstance(result, str):
|
||||
text_parts.append(result[:500]) # Limit length
|
||||
return '\n'.join(text_parts)
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def extract_tool_calls(content: Any, message_uuid: str, timestamp: str) -> List[Dict[str, Any]]:
|
||||
"""Extract tool use blocks from message content"""
|
||||
tool_calls = []
|
||||
|
||||
if not isinstance(content, list):
|
||||
return tool_calls
|
||||
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get('type') == 'tool_use':
|
||||
tool_calls.append({
|
||||
'message_uuid': message_uuid,
|
||||
'tool_use_id': block.get('id'),
|
||||
'tool_name': block.get('name'),
|
||||
'tool_input': json.dumps(block.get('input', {})),
|
||||
'tool_result': None,
|
||||
'is_error': False,
|
||||
'timestamp': timestamp
|
||||
})
|
||||
|
||||
return tool_calls
|
||||
|
||||
|
||||
def extract_tool_results(content: Any, tool_calls_map: Dict[str, Dict]) -> None:
|
||||
"""Extract tool results and match them to tool calls"""
|
||||
if not isinstance(content, list):
|
||||
return
|
||||
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get('type') == 'tool_result':
|
||||
tool_use_id = block.get('tool_use_id')
|
||||
if tool_use_id and tool_use_id in tool_calls_map:
|
||||
result_content = block.get('content', '')
|
||||
if isinstance(result_content, str):
|
||||
tool_calls_map[tool_use_id]['tool_result'] = result_content[:5000] # Limit length
|
||||
tool_calls_map[tool_use_id]['is_error'] = block.get('is_error', False)
|
||||
|
||||
|
||||
def extract_message_data(obj: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Extract relevant fields from a message object"""
|
||||
msg_type = obj.get('type')
|
||||
|
||||
# Only process user and assistant messages
|
||||
if msg_type not in ['user', 'assistant']:
|
||||
return None
|
||||
|
||||
message = obj.get('message', {})
|
||||
role = message.get('role')
|
||||
|
||||
if not role or role not in ['user', 'assistant']:
|
||||
return None
|
||||
|
||||
content = message.get('content', '')
|
||||
has_tool_use = False
|
||||
has_thinking = False
|
||||
|
||||
# Check for tool use and thinking blocks
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get('type') == 'tool_use':
|
||||
has_tool_use = True
|
||||
elif block.get('type') == 'thinking':
|
||||
has_thinking = True
|
||||
|
||||
# Extract usage metrics
|
||||
usage = message.get('usage', {})
|
||||
input_tokens = usage.get('input_tokens', 0)
|
||||
output_tokens = usage.get('output_tokens', 0)
|
||||
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'message_uuid': obj.get('uuid'),
|
||||
'parent_uuid': obj.get('parentUuid'),
|
||||
'role': role,
|
||||
'content_text': extract_text_content(content),
|
||||
'timestamp': obj.get('timestamp'),
|
||||
'prompt_id': obj.get('promptId'),
|
||||
'model': message.get('model'),
|
||||
'input_tokens': input_tokens,
|
||||
'output_tokens': output_tokens,
|
||||
'has_tool_use': has_tool_use,
|
||||
'has_thinking': has_thinking,
|
||||
'stop_reason': message.get('stop_reason')
|
||||
}
|
||||
|
||||
|
||||
def calculate_conversation_stats(messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Calculate aggregate statistics for a conversation"""
|
||||
stats = {
|
||||
'message_count': 0,
|
||||
'user_message_count': 0,
|
||||
'assistant_message_count': 0,
|
||||
'total_input_tokens': 0,
|
||||
'total_output_tokens': 0,
|
||||
'started_at': None,
|
||||
'last_updated_at': None,
|
||||
'model': None
|
||||
}
|
||||
|
||||
for msg in messages:
|
||||
if msg.get('role') == 'user':
|
||||
stats['user_message_count'] += 1
|
||||
elif msg.get('role') == 'assistant':
|
||||
stats['assistant_message_count'] += 1
|
||||
if not stats['model'] and msg.get('model'):
|
||||
stats['model'] = msg.get('model')
|
||||
|
||||
stats['message_count'] += 1
|
||||
stats['total_input_tokens'] += msg.get('input_tokens', 0)
|
||||
stats['total_output_tokens'] += msg.get('output_tokens', 0)
|
||||
|
||||
timestamp = msg.get('timestamp')
|
||||
if timestamp:
|
||||
if not stats['started_at'] or timestamp < stats['started_at']:
|
||||
stats['started_at'] = timestamp
|
||||
if not stats['last_updated_at'] or timestamp > stats['last_updated_at']:
|
||||
stats['last_updated_at'] = timestamp
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def parse_conversation_file(file_path: str, start_line: int = 0, end_line: int = None) -> Dict[str, Any]:
|
||||
"""Parse a conversation file and extract all data"""
|
||||
raw_messages = parse_jsonl_file(file_path, start_line, end_line)
|
||||
|
||||
if not raw_messages:
|
||||
return None
|
||||
|
||||
# Extract session metadata from first message
|
||||
session_id = None
|
||||
project_path = None
|
||||
git_branch = None
|
||||
slug = None
|
||||
|
||||
for obj in raw_messages:
|
||||
if obj.get('sessionId'):
|
||||
session_id = obj['sessionId']
|
||||
if obj.get('cwd'):
|
||||
project_path = obj['cwd']
|
||||
if obj.get('gitBranch'):
|
||||
git_branch = obj['gitBranch']
|
||||
if obj.get('slug'):
|
||||
slug = obj['slug']
|
||||
|
||||
if session_id and project_path:
|
||||
break
|
||||
|
||||
if not session_id:
|
||||
logger.warning(f"No session ID found in {file_path}")
|
||||
return None
|
||||
|
||||
# Extract messages
|
||||
messages = []
|
||||
tool_calls_map = {} # Map tool_use_id to tool call data
|
||||
|
||||
for obj in raw_messages:
|
||||
msg_data = extract_message_data(obj, session_id)
|
||||
if msg_data:
|
||||
messages.append(msg_data)
|
||||
|
||||
# Extract tool calls from assistant messages
|
||||
if msg_data['role'] == 'assistant' and msg_data['has_tool_use']:
|
||||
message_content = obj.get('message', {}).get('content', [])
|
||||
tool_calls = extract_tool_calls(message_content, msg_data['message_uuid'], msg_data['timestamp'])
|
||||
for tc in tool_calls:
|
||||
tool_calls_map[tc['tool_use_id']] = tc
|
||||
|
||||
# Extract tool results from user messages
|
||||
if obj.get('type') == 'user':
|
||||
message_content = obj.get('message', {}).get('content', [])
|
||||
extract_tool_results(message_content, tool_calls_map)
|
||||
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
# Calculate stats
|
||||
stats = calculate_conversation_stats(messages)
|
||||
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'project_path': project_path or 'unknown',
|
||||
'git_branch': git_branch,
|
||||
'slug': slug,
|
||||
'file_path': file_path,
|
||||
'messages': messages,
|
||||
'tool_calls': list(tool_calls_map.values()),
|
||||
**stats
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
aiosqlite==0.19.0
|
||||
anthropic==0.39.0
|
||||
httpx==0.27.0
|
||||
@@ -0,0 +1,174 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio
|
||||
import aiosqlite
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
|
||||
ANTHROPIC_BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "https://www.bytecatcode.org")
|
||||
SUMMARY_HOUR = int(os.environ.get("SUMMARY_HOUR", "23"))
|
||||
TRIGGER_PATH = os.environ.get("TRIGGER_PATH", "/app/data/trigger")
|
||||
|
||||
|
||||
async def generate_summary_for_date(date_str: str):
|
||||
"""Generate summary for a specific date"""
|
||||
try:
|
||||
logger.info(f"Generating summary for {date_str}")
|
||||
|
||||
# Query conversations for this date
|
||||
async with aiosqlite.connect(db.DB_PATH) as conn:
|
||||
# Get conversations
|
||||
cursor = await conn.execute("""
|
||||
SELECT session_id, project_path, slug, message_count,
|
||||
total_input_tokens, total_output_tokens
|
||||
FROM conversations
|
||||
WHERE DATE(started_at) = ? OR DATE(last_updated_at) = ?
|
||||
ORDER BY started_at
|
||||
""", (date_str, date_str))
|
||||
conversations = await cursor.fetchall()
|
||||
|
||||
if not conversations:
|
||||
logger.info(f"No conversations found for {date_str}")
|
||||
return
|
||||
|
||||
# Get sample messages from each conversation
|
||||
conversation_summaries = []
|
||||
total_messages = 0
|
||||
total_tokens = 0
|
||||
projects = set()
|
||||
|
||||
for conv in conversations:
|
||||
session_id, project_path, slug, msg_count, input_tokens, output_tokens = conv
|
||||
projects.add(project_path)
|
||||
total_messages += msg_count
|
||||
total_tokens += (input_tokens + output_tokens)
|
||||
|
||||
# Get first few user messages to understand what was worked on
|
||||
cursor = await conn.execute("""
|
||||
SELECT content_text FROM messages
|
||||
WHERE session_id = ? AND role = 'user' AND content_text IS NOT NULL
|
||||
ORDER BY timestamp
|
||||
LIMIT 3
|
||||
""", (session_id,))
|
||||
user_messages = await cursor.fetchall()
|
||||
|
||||
# Get tool usage
|
||||
cursor = await conn.execute("""
|
||||
SELECT tool_name, COUNT(*) as count
|
||||
FROM tool_calls
|
||||
WHERE message_uuid IN (
|
||||
SELECT message_uuid FROM messages WHERE session_id = ?
|
||||
)
|
||||
GROUP BY tool_name
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
""", (session_id,))
|
||||
tools = await cursor.fetchall()
|
||||
|
||||
conversation_summaries.append({
|
||||
'slug': slug or 'Untitled',
|
||||
'project': project_path.split('/')[-1] if project_path else 'unknown',
|
||||
'messages': msg_count,
|
||||
'tokens': input_tokens + output_tokens,
|
||||
'user_messages': [msg[0][:200] for msg in user_messages if msg[0]],
|
||||
'tools': [f"{tool[0]} ({tool[1]}x)" for tool in tools]
|
||||
})
|
||||
|
||||
# Format for Claude
|
||||
formatted_convs = []
|
||||
for i, conv in enumerate(conversation_summaries, 1):
|
||||
formatted_convs.append(f"""
|
||||
**Conversation {i}: {conv['slug']}**
|
||||
- Project: {conv['project']}
|
||||
- Messages: {conv['messages']} ({conv['tokens']} tokens)
|
||||
- Initial requests: {', '.join(conv['user_messages'][:2]) if conv['user_messages'] else 'N/A'}
|
||||
- Tools used: {', '.join(conv['tools']) if conv['tools'] else 'None'}
|
||||
""")
|
||||
|
||||
prompt = f"""You are analyzing a developer's Claude Code conversations from {date_str}.
|
||||
|
||||
**Overview:**
|
||||
- Conversations: {len(conversations)}
|
||||
- Total messages: {total_messages}
|
||||
- Total tokens: {total_tokens:,}
|
||||
- Projects: {', '.join(sorted(projects))}
|
||||
|
||||
**Conversations:**
|
||||
{''.join(formatted_convs)}
|
||||
|
||||
Generate a concise daily summary covering:
|
||||
1. Main tasks and goals worked on
|
||||
2. Key decisions and solutions implemented
|
||||
3. Files and components modified (if evident from tool usage)
|
||||
4. Challenges encountered and how they were resolved
|
||||
5. Tools and patterns used
|
||||
6. Overall progress and outcomes
|
||||
|
||||
Use markdown formatting. Be specific and technical. Focus on what was accomplished. Keep it under 500 words."""
|
||||
|
||||
# Call Claude API
|
||||
client = AsyncAnthropic(api_key=ANTHROPIC_API_KEY, base_url=ANTHROPIC_BASE_URL)
|
||||
response = await client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=4096,
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
|
||||
summary_content = response.content[0].text
|
||||
|
||||
# Store summary
|
||||
await db.upsert_summary(date_str, {
|
||||
'project_path': ', '.join(sorted(projects)),
|
||||
'conversation_count': len(conversations),
|
||||
'total_messages': total_messages,
|
||||
'total_tokens': total_tokens,
|
||||
'summary_content': summary_content
|
||||
})
|
||||
|
||||
logger.info(f"Summary generated for {date_str}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating summary for {date_str}: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def check_trigger():
|
||||
"""Check for manual trigger file"""
|
||||
if os.path.exists(TRIGGER_PATH):
|
||||
try:
|
||||
os.remove(TRIGGER_PATH)
|
||||
logger.info("Manual trigger detected")
|
||||
# Generate summary for yesterday
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
await generate_summary_for_date(yesterday)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing trigger: {e}")
|
||||
|
||||
|
||||
async def summarize_daily():
|
||||
"""Daily summarization loop"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now()
|
||||
|
||||
# Check for manual trigger
|
||||
await check_trigger()
|
||||
|
||||
# Check if it's time for daily summary
|
||||
if now.hour == SUMMARY_HOUR and now.minute < 5:
|
||||
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
await generate_summary_for_date(yesterday)
|
||||
|
||||
# Sleep until next hour to avoid duplicate runs
|
||||
await asyncio.sleep(3600)
|
||||
else:
|
||||
# Check every 5 minutes
|
||||
await asyncio.sleep(300)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in summarization loop: {e}", exc_info=True)
|
||||
await asyncio.sleep(300)
|
||||
+2
-54
@@ -1,57 +1,4 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
fd-find \
|
||||
gh \
|
||||
git \
|
||||
jq \
|
||||
make \
|
||||
openssh-client \
|
||||
python3 \
|
||||
ripgrep \
|
||||
rsync \
|
||||
tmux \
|
||||
unzip \
|
||||
wget \
|
||||
zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
|
||||
|
||||
ARG YQ_VERSION=v4.44.6
|
||||
RUN wget -q -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \
|
||||
&& chmod +x /usr/local/bin/yq
|
||||
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
RUN python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
path = Path("/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js")
|
||||
if not path.exists():
|
||||
raise SystemExit(f"claude cli not found: {path}")
|
||||
|
||||
content = path.read_text()
|
||||
pattern = r'let A=U7\(\),q=new URL\(A\.TOKEN_URL\),K=\[`\$\{A\.BASE_API_URL\}/api/hello`,`\$\{q\.origin\}/v1/oauth/hello`\],Y=async\(_\)=>\{try\{let \$=await I8\.get\(_,\{headers:\{"User-Agent":ey\(\)\}\}\);if\(\$\.status!==200\)return\{success:!1,error:`Failed to connect to \$\{new URL\(_\)\.hostname\}: Status \$\{\$\.status\}`\};return\{success:!0\}\}catch\(\$\)\{'
|
||||
replacement = 'return'
|
||||
patched, count = re.subn(pattern, replacement, content, count=1)
|
||||
if count != 1:
|
||||
raise SystemExit("preflight patch target not found in claude cli; refusing to build")
|
||||
|
||||
path.write_text(patched)
|
||||
|
||||
if re.search(pattern, path.read_text()):
|
||||
raise SystemExit("preflight patch did not apply cleanly")
|
||||
|
||||
print("Applied Claude preflight bypass patch")
|
||||
PY
|
||||
FROM claude-dev-base:latest
|
||||
|
||||
WORKDIR /repos/nas-tools
|
||||
|
||||
@@ -61,3 +8,4 @@ COPY scripts /opt/claude-dev/scripts
|
||||
RUN chmod +x /opt/claude-dev/scripts/*.sh
|
||||
|
||||
CMD ["bash"]
|
||||
# CI test 1775295475
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
fd-find \
|
||||
gh \
|
||||
git \
|
||||
jq \
|
||||
make \
|
||||
openssh-client \
|
||||
python3 \
|
||||
ripgrep \
|
||||
rsync \
|
||||
tmux \
|
||||
unzip \
|
||||
wget \
|
||||
zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
|
||||
|
||||
ARG YQ_VERSION=v4.44.6
|
||||
RUN wget -q -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \
|
||||
&& chmod +x /usr/local/bin/yq
|
||||
|
||||
ARG TEA_VERSION=0.12.0
|
||||
RUN wget -q -O /usr/local/bin/tea "https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64" \
|
||||
&& chmod +x /usr/local/bin/tea
|
||||
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
RUN python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
path = Path("/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js")
|
||||
if not path.exists():
|
||||
raise SystemExit(f"claude cli not found: {path}")
|
||||
|
||||
content = path.read_text()
|
||||
pattern = r'let A=U7\(\),q=new URL\(A\.TOKEN_URL\),K=\[`\$\{A\.BASE_API_URL\}/api/hello`,`\$\{q\.origin\}/v1/oauth/hello`\],Y=async\(_\)=>\{try\{let \$=await I8\.get\(_,\{headers:\{"User-Agent":ey\(\)\}\}\);if\(\$\.status!==200\)return\{success:!1,error:`Failed to connect to \$\{new URL\(_\)\.hostname\}: Status \$\{\$\.status\}`\};return\{success:!0\}\}catch\(\$\)\{'
|
||||
replacement = 'return'
|
||||
patched, count = re.subn(pattern, replacement, content, count=1)
|
||||
if count != 1:
|
||||
raise SystemExit("preflight patch target not found in claude cli; refusing to build")
|
||||
|
||||
path.write_text(patched)
|
||||
|
||||
if re.search(pattern, path.read_text()):
|
||||
raise SystemExit("preflight patch did not apply cleanly")
|
||||
|
||||
print("Applied Claude preflight bypass patch")
|
||||
PY
|
||||
|
||||
CMD ["bash"]
|
||||
@@ -0,0 +1 @@
|
||||
# Test trigger for CI
|
||||
@@ -14,4 +14,5 @@ zip
|
||||
unzip
|
||||
make
|
||||
tmux
|
||||
tea
|
||||
claude
|
||||
|
||||
@@ -6,7 +6,7 @@ check_http_status() {
|
||||
local allowed_regex="$2"
|
||||
|
||||
local code
|
||||
code=$(curl -sS -o /dev/null -m 10 -w "%{http_code}" "$url")
|
||||
code=$(curl -sS -o /dev/null -m 10 -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||
if [[ ! "$code" =~ $allowed_regex ]]; then
|
||||
echo "Unexpected HTTP status from $url: $code" >&2
|
||||
return 1
|
||||
@@ -14,14 +14,18 @@ check_http_status() {
|
||||
echo "$url => HTTP $code"
|
||||
}
|
||||
|
||||
if ! getent hosts api.bytecatcode.org >/dev/null 2>&1; then
|
||||
echo "DNS lookup failed for api.bytecatcode.org" >&2
|
||||
if ! getent hosts www.bytecatcode.org >/dev/null 2>&1; then
|
||||
echo "DNS lookup failed for www.bytecatcode.org" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "DNS lookup passed for api.bytecatcode.org"
|
||||
echo "DNS lookup passed for www.bytecatcode.org"
|
||||
|
||||
# Check external API with longer timeout and allow connection failures in CI
|
||||
if ! check_http_status "https://www.bytecatcode.org" '^(000|200|301|302|400|401|403|404|502|503)$'; then
|
||||
echo "Warning: External API check failed, but continuing (may be network isolation in CI)" >&2
|
||||
fi
|
||||
|
||||
check_http_status "https://api.bytecatcode.org" '^(200|301|302|400|401|403|404)$'
|
||||
check_http_status "http://127.0.0.1:3300" '^(200|301|302|401|403|404)$'
|
||||
|
||||
echo "Network smoke checks passed"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.vscode
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
@@ -3,7 +3,8 @@ FROM node:20-alpine AS frontend
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
COPY frontend/ ./
|
||||
RUN npm install --registry=https://registry.npmmirror.com && npm run build
|
||||
RUN npm install --registry=https://registry.npmmirror.com || npm install
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Python runtime
|
||||
FROM python:3.12-slim
|
||||
@@ -11,7 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssh-client
|
||||
RUN adduser --disabled-password --no-create-home --gecos "" app
|
||||
WORKDIR /app
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --index-url https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt || \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
COPY backend/ ./
|
||||
COPY --from=frontend /build/dist /app/static
|
||||
RUN chown -R app:app /app
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Dashboard
|
||||
@@ -0,0 +1 @@
|
||||
# Test CI trigger - Mon Apr 6 00:53:37 CST 2026
|
||||
@@ -0,0 +1,34 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
*.cover
|
||||
coverage.xml
|
||||
test-results.xml
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Pre-commit
|
||||
.pre-commit-config.yaml.bak
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,10 @@
|
||||
# OPC fixes deployed Fri Apr 3 22:55:21 CST 2026
|
||||
# CI fix deployed Fri Apr 3 23:17:25 CST 2026
|
||||
# CI test 1775230283
|
||||
# CI test after restart 1775230492
|
||||
# Test deploy after runner restart
|
||||
# Trigger fresh deploy after fix
|
||||
# Trigger deploy after manual cleanup
|
||||
# Test force-recreate approach
|
||||
# Trigger deploy with updated script
|
||||
# Test trigger 1775312439
|
||||
@@ -0,0 +1,24 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.10.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.12
|
||||
args: [--line-length=120]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=1000]
|
||||
- id: check-merge-conflict
|
||||
- id: check-json
|
||||
@@ -0,0 +1,85 @@
|
||||
# Pre-commit Hooks Setup
|
||||
|
||||
This project uses pre-commit hooks to ensure code quality before commits.
|
||||
|
||||
## Tools
|
||||
|
||||
- **black**: Code formatter (line length: 120)
|
||||
- **ruff**: Fast Python linter
|
||||
- **pre-commit-hooks**: Basic file checks (trailing whitespace, YAML validation, etc.)
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Manual Installation (Recommended for this repo)
|
||||
|
||||
Since this repo has a custom `core.hooksPath`, install pre-commit manually:
|
||||
|
||||
```bash
|
||||
cd dashboard/backend
|
||||
source venv/bin/activate
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# Run manually before committing
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Option 2: Standard Installation
|
||||
|
||||
If you want to use pre-commit's automatic hooks:
|
||||
|
||||
```bash
|
||||
cd /path/to/nas-tools
|
||||
git config --unset-all core.hooksPath
|
||||
cd dashboard/backend
|
||||
source venv/bin/activate
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Run on all files
|
||||
```bash
|
||||
cd dashboard/backend
|
||||
source venv/bin/activate
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Run on staged files only
|
||||
```bash
|
||||
pre-commit run
|
||||
```
|
||||
|
||||
### Run specific hook
|
||||
```bash
|
||||
pre-commit run black --all-files
|
||||
pre-commit run ruff --all-files
|
||||
```
|
||||
|
||||
### Skip hooks (not recommended)
|
||||
```bash
|
||||
git commit --no-verify
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- `.pre-commit-config.yaml`: Hook configuration
|
||||
- `pyproject.toml`: Black and Ruff settings
|
||||
|
||||
## What the hooks check
|
||||
|
||||
1. **black**: Formats Python code to consistent style
|
||||
2. **ruff**: Checks for common Python errors and style issues
|
||||
3. **trailing-whitespace**: Removes trailing whitespace
|
||||
4. **end-of-file-fixer**: Ensures files end with newline
|
||||
5. **check-yaml**: Validates YAML syntax
|
||||
6. **check-json**: Validates JSON syntax
|
||||
7. **check-merge-conflict**: Detects merge conflict markers
|
||||
8. **check-added-large-files**: Prevents committing large files (>1MB)
|
||||
|
||||
## CI Integration
|
||||
|
||||
The test workflow runs these checks automatically:
|
||||
- Tests must pass
|
||||
- Coverage must be ≥50%
|
||||
|
||||
Pre-commit hooks help catch issues locally before pushing to CI.
|
||||
@@ -1,13 +1,14 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
import time
|
||||
import threading
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
import pyotp
|
||||
from fastapi import Depends, HTTPException, Response, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from passlib.context import CryptContext
|
||||
|
||||
import config
|
||||
|
||||
# Password handling
|
||||
@@ -16,26 +17,31 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
||||
|
||||
ACCESS_COOKIE_NAME = "nas_access_token"
|
||||
REFRESH_COOKIE_NAME = "nas_refresh_token"
|
||||
COOKIE_SECURE = True
|
||||
# Allow both HTTP and HTTPS by setting secure=False
|
||||
# In production, Caddy terminates TLS and forwards to backend over HTTP
|
||||
COOKIE_SECURE = False
|
||||
COOKIE_SAMESITE = "lax"
|
||||
COOKIE_PATH = "/"
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password):
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
|
||||
expire = datetime.now(UTC) + (expires_delta or timedelta(minutes=15))
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.now(UTC) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
|
||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||
|
||||
@@ -69,8 +75,10 @@ def clear_auth_cookies(response: Response):
|
||||
response.delete_cookie(ACCESS_COOKIE_NAME, path=COOKIE_PATH)
|
||||
response.delete_cookie(REFRESH_COOKIE_NAME, path=COOKIE_PATH)
|
||||
|
||||
|
||||
def user_from_access_token(token: str, include_auth_header: bool = True):
|
||||
from rbac import User, get_pages, get_sidebar_links
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
@@ -99,11 +107,17 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
async def get_current_user_ws(token: str):
|
||||
return user_from_access_token(token, include_auth_header=False)
|
||||
|
||||
|
||||
# TOTP Persistence
|
||||
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
||||
import json, os
|
||||
def get_auth_file():
|
||||
return config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
||||
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
_auth_lock = threading.RLock()
|
||||
@@ -127,25 +141,30 @@ def _update_auth_data(mutator):
|
||||
with _auth_lock:
|
||||
data = _load_auth_data()
|
||||
result = mutator(data)
|
||||
_atomic_json_write(AUTH_FILE, data)
|
||||
_atomic_json_write(get_auth_file(), data)
|
||||
return result
|
||||
|
||||
|
||||
def _get_fernet():
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=b"nas-dashboard-fernet", iterations=480000)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(config.SECRET_KEY.encode()))
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def _get_legacy_fernet():
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest())
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def _encrypt(plaintext: str) -> str:
|
||||
if not plaintext:
|
||||
return ""
|
||||
return _get_fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def _decrypt(ciphertext: str) -> str:
|
||||
if not ciphertext:
|
||||
return ""
|
||||
@@ -159,18 +178,22 @@ def _decrypt(ciphertext: str) -> str:
|
||||
except Exception:
|
||||
return ciphertext # fallback for unencrypted legacy values
|
||||
|
||||
|
||||
def _load_auth_data():
|
||||
with _auth_lock:
|
||||
try:
|
||||
if os.path.exists(AUTH_FILE):
|
||||
with open(AUTH_FILE, "r") as f:
|
||||
auth_path = get_auth_file()
|
||||
if os.path.exists(auth_path):
|
||||
with open(auth_path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_auth_data(data):
|
||||
_atomic_json_write(AUTH_FILE, data)
|
||||
_atomic_json_write(get_auth_file(), data)
|
||||
|
||||
|
||||
def load_totp_secret():
|
||||
encrypted = _load_auth_data().get("totp_secret", "")
|
||||
@@ -178,63 +201,90 @@ def load_totp_secret():
|
||||
return config.TOTP_SECRET
|
||||
return _decrypt(encrypted)
|
||||
|
||||
|
||||
def save_totp_secret(secret: str):
|
||||
def mutator(data):
|
||||
data["totp_secret"] = _encrypt(secret) if secret else ""
|
||||
|
||||
_update_auth_data(mutator)
|
||||
|
||||
|
||||
def load_password_hash():
|
||||
return _load_auth_data().get("password_hash", "") or config.ADMIN_PASSWORD_HASH
|
||||
|
||||
|
||||
def save_password_hash(hashed: str):
|
||||
def mutator(data):
|
||||
data["password_hash"] = hashed
|
||||
|
||||
_update_auth_data(mutator)
|
||||
|
||||
|
||||
def verify_totp(token: str, secret: str = None):
|
||||
if secret is None:
|
||||
secret = load_totp_secret()
|
||||
if not secret:
|
||||
return True # 2FA not enabled
|
||||
totp = pyotp.TOTP(secret)
|
||||
if not totp.verify(token):
|
||||
if not totp.verify(token, valid_window=1): # Allow 1 time step before/after (±30s)
|
||||
return False
|
||||
|
||||
def mutator(data):
|
||||
current_ts = int(time.time()) // 30
|
||||
if data.get("last_totp_ts") == current_ts:
|
||||
# Track recently used codes to prevent replay attacks
|
||||
used_codes = data.get("used_totp_codes", [])
|
||||
current_time = int(time.time())
|
||||
|
||||
# Check if this code was already used
|
||||
if token in [code for code, _ in used_codes]:
|
||||
return False
|
||||
data["last_totp_ts"] = current_ts
|
||||
|
||||
# Add current code with timestamp
|
||||
used_codes.append((token, current_time))
|
||||
|
||||
# Clean up codes older than 90 seconds (3 time windows)
|
||||
used_codes = [(code, ts) for code, ts in used_codes if current_time - ts < 90]
|
||||
|
||||
# Keep only last 5 codes to limit memory
|
||||
data["used_totp_codes"] = used_codes[-5:]
|
||||
return True
|
||||
|
||||
return _update_auth_data(mutator)
|
||||
|
||||
|
||||
# Passkey credentials
|
||||
def load_passkey_credentials():
|
||||
return _load_auth_data().get("passkey_credentials", [])
|
||||
|
||||
|
||||
def save_passkey_credential(cred):
|
||||
def mutator(data):
|
||||
creds = data.get("passkey_credentials", [])
|
||||
creds.append(cred)
|
||||
data["passkey_credentials"] = creds
|
||||
|
||||
_update_auth_data(mutator)
|
||||
|
||||
|
||||
def clear_passkey_credentials():
|
||||
def mutator(data):
|
||||
data["passkey_credentials"] = []
|
||||
|
||||
_update_auth_data(mutator)
|
||||
|
||||
|
||||
# Token version for refresh token rotation
|
||||
def _load_token_version() -> int:
|
||||
return _load_auth_data().get("token_version", 0)
|
||||
|
||||
|
||||
def increment_token_version() -> int:
|
||||
def mutator(data):
|
||||
v = data.get("token_version", 0) + 1
|
||||
data["token_version"] = v
|
||||
return v
|
||||
|
||||
return _update_auth_data(mutator)
|
||||
|
||||
|
||||
def verify_token_version(tv: int) -> bool:
|
||||
return tv == _load_token_version()
|
||||
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
import ipaddress
|
||||
import os
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
# Dashboard v1.3 — Runner DNS fix applied
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000")
|
||||
@@ -42,7 +44,9 @@ TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
|
||||
|
||||
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "jimmygan.com")
|
||||
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
||||
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(
|
||||
","
|
||||
)
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
||||
@@ -50,6 +54,9 @@ CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://
|
||||
# Chat Summary
|
||||
CHAT_SUMMARY_DB = os.environ.get("CHAT_SUMMARY_DB", "/app/data/chat-summarizer/chat_summary.db")
|
||||
|
||||
# Conversation Tracker
|
||||
CONVERSATION_TRACKER_DB = os.environ.get("CONVERSATION_TRACKER_DB", "/app/data/claude-code-tracker/conversations.db")
|
||||
|
||||
# Info Engine
|
||||
INFO_ENGINE_DB = os.environ.get("INFO_ENGINE_DB", "/app/data/info-engine/info_engine.db")
|
||||
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
"""
|
||||
OPC Database Module - PostgreSQL connection and schema management
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
# Database connection settings
|
||||
DB_HOST = os.getenv("OPC_DB_HOST", "gitea-db")
|
||||
DB_PORT = int(os.getenv("OPC_DB_PORT", "5432"))
|
||||
DB_NAME = os.getenv("OPC_DB_NAME", "opc")
|
||||
DB_USER = os.getenv("OPC_DB_USER", "gitea")
|
||||
DB_PASSWORD = os.getenv("GITEA_DB_PASSWORD", "")
|
||||
|
||||
# Connection pool
|
||||
_pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
"""Get or create database connection pool with retry logic"""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
max_retries = 3
|
||||
retry_delay = 5
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info(f"Attempting to connect to OPC database (attempt {attempt + 1}/{max_retries})")
|
||||
_pool = await asyncpg.create_pool(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
database=DB_NAME,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
)
|
||||
logger.info("Successfully connected to OPC database")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to OPC database (attempt {attempt + 1}/{max_retries}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
logger.info(f"Retrying in {retry_delay} seconds...")
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
logger.error("All connection attempts failed")
|
||||
raise Exception(f"Failed to connect to OPC database after {max_retries} attempts: {e}")
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
"""Close database connection pool"""
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database schema"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Create tables
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
client_id INTEGER,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'parking_lot',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
project_id INTEGER REFERENCES projects(id),
|
||||
assigned_to TEXT,
|
||||
assigned_type TEXT DEFAULT 'human',
|
||||
tags JSONB,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
due_date TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_type TEXT DEFAULT 'human',
|
||||
old_value JSONB,
|
||||
new_value JSONB,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
description TEXT,
|
||||
capabilities JSONB,
|
||||
system_prompt TEXT,
|
||||
config JSONB,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agent_executions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id),
|
||||
status TEXT DEFAULT 'pending',
|
||||
input_context JSONB,
|
||||
output_result JSONB,
|
||||
actions_proposed JSONB,
|
||||
actions_executed JSONB,
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
requires_approval BOOLEAN DEFAULT FALSE,
|
||||
approved_by TEXT,
|
||||
approved_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS time_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
project_id INTEGER REFERENCES projects(id),
|
||||
description TEXT,
|
||||
duration_minutes INTEGER NOT NULL,
|
||||
billable BOOLEAN DEFAULT TRUE,
|
||||
hourly_rate NUMERIC(10, 2),
|
||||
started_at TIMESTAMP,
|
||||
ended_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
company TEXT,
|
||||
notes TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to, assigned_type)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_task_history_task ON task_history(task_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_executions_task ON agent_executions(task_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_executions_agent ON agent_executions(agent_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_time_entries_task ON time_entries(task_id)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_time_entries_project ON time_entries(project_id)")
|
||||
|
||||
# Insert default agents
|
||||
await seed_agents(conn)
|
||||
|
||||
|
||||
async def seed_agents(conn):
|
||||
"""Seed default agents"""
|
||||
agents = [
|
||||
{
|
||||
"id": "pm",
|
||||
"name": "Project Manager",
|
||||
"role": "Project Manager",
|
||||
"description": "Breaks down tasks, plans timelines, monitors progress",
|
||||
"capabilities": ["task_breakdown", "timeline_planning", "risk_assessment", "status_reporting"],
|
||||
"system_prompt": """You are a Project Manager agent. Your role is to:
|
||||
- Break down large tasks into manageable subtasks
|
||||
- Plan realistic timelines and identify dependencies
|
||||
- Monitor project progress and identify blockers
|
||||
- Provide status reports and risk assessments
|
||||
|
||||
When assigned a task, analyze it and propose concrete actions.""",
|
||||
"config": {"model": "cc-kiro", "temperature": 0.7, "approval_level": "auto"},
|
||||
},
|
||||
{
|
||||
"id": "cto",
|
||||
"name": "CTO",
|
||||
"role": "Chief Technology Officer",
|
||||
"description": "Reviews code, designs architecture, manages technical debt",
|
||||
"capabilities": ["code_review", "architecture_design", "tech_debt_management", "security_audit"],
|
||||
"system_prompt": """You are a CTO agent. Your role is to:
|
||||
- Review code quality and architecture decisions
|
||||
- Design scalable and maintainable systems
|
||||
- Identify and prioritize technical debt
|
||||
- Ensure security best practices
|
||||
|
||||
When assigned a task, provide technical leadership and propose improvements.""",
|
||||
"config": {"model": "cc-kiro", "temperature": 0.5, "approval_level": "cxo"},
|
||||
},
|
||||
{
|
||||
"id": "coo",
|
||||
"name": "COO",
|
||||
"role": "Chief Operating Officer",
|
||||
"description": "Optimizes processes, automates workflows, improves efficiency",
|
||||
"capabilities": ["process_improvement", "workflow_automation", "efficiency_analysis"],
|
||||
"system_prompt": """You are a COO agent. Your role is to:
|
||||
- Identify opportunities for process improvement
|
||||
- Automate repetitive workflows using n8n
|
||||
- Analyze operational efficiency
|
||||
- Optimize resource allocation
|
||||
|
||||
When assigned a task, look for automation and optimization opportunities.""",
|
||||
"config": {"model": "cc-kiro", "temperature": 0.6, "approval_level": "cxo"},
|
||||
},
|
||||
]
|
||||
|
||||
for agent in agents:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO agents (id, name, role, description, capabilities, system_prompt, config, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
role = EXCLUDED.role,
|
||||
description = EXCLUDED.description,
|
||||
capabilities = EXCLUDED.capabilities,
|
||||
system_prompt = EXCLUDED.system_prompt,
|
||||
config = EXCLUDED.config
|
||||
""",
|
||||
agent["id"],
|
||||
agent["name"],
|
||||
agent["role"],
|
||||
agent["description"],
|
||||
json.dumps(agent["capabilities"]),
|
||||
agent["system_prompt"],
|
||||
json.dumps(agent["config"]),
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
# Task operations
|
||||
async def create_task(
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
status: str = "parking_lot",
|
||||
priority: str = "medium",
|
||||
project_id: int | None = None,
|
||||
assigned_to: str | None = None,
|
||||
assigned_type: str = "human",
|
||||
tags: list[str] | None = None,
|
||||
due_date: datetime | None = None,
|
||||
actor: str = "system",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new task"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tasks (title, description, status, priority, project_id, assigned_to, assigned_type, tags, due_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, title, description, status, priority, project_id, assigned_to, assigned_type, tags,
|
||||
created_at, updated_at, completed_at, due_date
|
||||
""",
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
project_id,
|
||||
assigned_to,
|
||||
assigned_type,
|
||||
json.dumps(tags or []),
|
||||
due_date,
|
||||
)
|
||||
|
||||
task = dict(row)
|
||||
task["tags"] = json.loads(task["tags"]) if task["tags"] else []
|
||||
|
||||
# Convert datetime objects to ISO format strings for JSON serialization
|
||||
task_for_history = task.copy()
|
||||
for key in ["created_at", "updated_at", "completed_at", "due_date"]:
|
||||
if task_for_history.get(key):
|
||||
task_for_history[key] = task_for_history[key].isoformat()
|
||||
|
||||
# Log history
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO task_history (task_id, action, actor, actor_type, new_value)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
task["id"],
|
||||
"created",
|
||||
actor,
|
||||
"human",
|
||||
json.dumps(task_for_history),
|
||||
)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
async def get_tasks(
|
||||
status: str | None = None,
|
||||
assigned_to: str | None = None,
|
||||
project_id: int | None = None,
|
||||
priority: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get tasks with filters"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
query = "SELECT * FROM tasks WHERE 1=1"
|
||||
params = []
|
||||
param_count = 0
|
||||
|
||||
if status:
|
||||
param_count += 1
|
||||
query += f" AND status = ${param_count}"
|
||||
params.append(status)
|
||||
|
||||
if assigned_to:
|
||||
param_count += 1
|
||||
query += f" AND assigned_to = ${param_count}"
|
||||
params.append(assigned_to)
|
||||
|
||||
if project_id:
|
||||
param_count += 1
|
||||
query += f" AND project_id = ${param_count}"
|
||||
params.append(project_id)
|
||||
|
||||
if priority:
|
||||
param_count += 1
|
||||
query += f" AND priority = ${param_count}"
|
||||
params.append(priority)
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
param_count += 1
|
||||
query += f" LIMIT ${param_count}"
|
||||
params.append(limit)
|
||||
param_count += 1
|
||||
query += f" OFFSET ${param_count}"
|
||||
params.append(offset)
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
tasks = [dict(row) for row in rows]
|
||||
|
||||
for task in tasks:
|
||||
task["tags"] = json.loads(task["tags"]) if task["tags"] else []
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
async def update_task(task_id: int, updates: dict[str, Any], actor: str = "system") -> dict[str, Any]:
|
||||
"""Update a task"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Get old values
|
||||
old_task = await conn.fetchrow("SELECT * FROM tasks WHERE id = $1", task_id)
|
||||
if not old_task:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
old_values = dict(old_task)
|
||||
|
||||
# Build update query
|
||||
set_clauses = ["updated_at = CURRENT_TIMESTAMP"]
|
||||
params = []
|
||||
param_count = 0
|
||||
|
||||
for key, value in updates.items():
|
||||
if key in [
|
||||
"title",
|
||||
"description",
|
||||
"status",
|
||||
"priority",
|
||||
"project_id",
|
||||
"assigned_to",
|
||||
"assigned_type",
|
||||
"due_date",
|
||||
"completed_at",
|
||||
]:
|
||||
param_count += 1
|
||||
set_clauses.append(f"{key} = ${param_count}")
|
||||
params.append(value)
|
||||
elif key == "tags":
|
||||
param_count += 1
|
||||
set_clauses.append(f"tags = ${param_count}")
|
||||
params.append(json.dumps(value))
|
||||
|
||||
param_count += 1
|
||||
params.append(task_id)
|
||||
|
||||
query = f"UPDATE tasks SET {', '.join(set_clauses)} WHERE id = ${param_count} RETURNING *"
|
||||
row = await conn.fetchrow(query, *params)
|
||||
|
||||
task = dict(row)
|
||||
task["tags"] = json.loads(task["tags"]) if task["tags"] else []
|
||||
|
||||
# Convert datetime objects to ISO format strings for JSON serialization
|
||||
old_values_for_history = old_values.copy()
|
||||
updates_for_history = updates.copy()
|
||||
for key in ["created_at", "updated_at", "completed_at", "due_date"]:
|
||||
if old_values_for_history.get(key):
|
||||
old_values_for_history[key] = old_values_for_history[key].isoformat()
|
||||
if updates_for_history.get(key):
|
||||
updates_for_history[key] = updates_for_history[key].isoformat()
|
||||
|
||||
# Log history
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO task_history (task_id, action, actor, actor_type, old_value, new_value)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
task_id,
|
||||
"updated",
|
||||
actor,
|
||||
"human",
|
||||
json.dumps(old_values_for_history),
|
||||
json.dumps(updates_for_history),
|
||||
)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
async def delete_task(task_id: int, actor: str = "system"):
|
||||
"""Delete a task"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO task_history (task_id, action, actor, actor_type)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
""",
|
||||
task_id,
|
||||
"deleted",
|
||||
actor,
|
||||
"human",
|
||||
)
|
||||
|
||||
await conn.execute("DELETE FROM tasks WHERE id = $1", task_id)
|
||||
|
||||
|
||||
# Time tracking operations
|
||||
async def start_time_entry(task_id: int, project_id: int | None = None, actor: str = "system") -> dict[str, Any]:
|
||||
"""Start automatic time tracking for a task"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Check if there's already an active entry
|
||||
active = await conn.fetchrow(
|
||||
"""
|
||||
SELECT * FROM time_entries
|
||||
WHERE task_id = $1 AND ended_at IS NULL
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
""",
|
||||
task_id,
|
||||
)
|
||||
|
||||
if active:
|
||||
return dict(active)
|
||||
|
||||
# Create new time entry
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO time_entries (task_id, project_id, started_at, duration_minutes)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP, 0)
|
||||
RETURNING *
|
||||
""",
|
||||
task_id,
|
||||
project_id,
|
||||
)
|
||||
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def stop_time_entry(task_id: int) -> dict[str, Any] | None:
|
||||
"""Stop automatic time tracking for a task"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Find active entry
|
||||
active = await conn.fetchrow(
|
||||
"""
|
||||
SELECT * FROM time_entries
|
||||
WHERE task_id = $1 AND ended_at IS NULL
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
""",
|
||||
task_id,
|
||||
)
|
||||
|
||||
if not active:
|
||||
return None
|
||||
|
||||
# Calculate duration and update
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE time_entries
|
||||
SET ended_at = CURRENT_TIMESTAMP,
|
||||
duration_minutes = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) / 60
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
""",
|
||||
active["id"],
|
||||
)
|
||||
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def get_time_entries(task_id: int | None = None, project_id: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Get time entries"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
query = "SELECT * FROM time_entries WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if task_id:
|
||||
params.append(task_id)
|
||||
query += f" AND task_id = ${len(params)}"
|
||||
|
||||
if project_id:
|
||||
params.append(project_id)
|
||||
query += f" AND project_id = ${len(params)}"
|
||||
|
||||
query += " ORDER BY started_at DESC"
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def get_task_history(task_id: int) -> list[dict[str, Any]]:
|
||||
"""Get task history"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM task_history
|
||||
WHERE task_id = $1
|
||||
ORDER BY timestamp DESC
|
||||
""",
|
||||
task_id,
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
# Agent operations
|
||||
async def get_agents(enabled_only: bool = True) -> list[dict[str, Any]]:
|
||||
"""Get all agents"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
query = "SELECT * FROM agents"
|
||||
if enabled_only:
|
||||
query += " WHERE enabled = TRUE"
|
||||
query += " ORDER BY id"
|
||||
|
||||
rows = await conn.fetch(query)
|
||||
agents = []
|
||||
for row in rows:
|
||||
agent = dict(row)
|
||||
agent["capabilities"] = json.loads(agent["capabilities"]) if agent["capabilities"] else []
|
||||
agent["config"] = json.loads(agent["config"]) if agent["config"] else {}
|
||||
agents.append(agent)
|
||||
return agents
|
||||
|
||||
|
||||
async def get_agent(agent_id: str) -> dict[str, Any] | None:
|
||||
"""Get agent by ID"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT * FROM agents WHERE id = $1", agent_id)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
agent = dict(row)
|
||||
agent["capabilities"] = json.loads(agent["capabilities"]) if agent["capabilities"] else []
|
||||
agent["config"] = json.loads(agent["config"]) if agent["config"] else {}
|
||||
return agent
|
||||
|
||||
|
||||
async def create_agent_execution(
|
||||
task_id: int, agent_id: str, actor: str = "system", requires_approval: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Create agent execution record"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Get task and agent context
|
||||
task = await conn.fetchrow("SELECT * FROM tasks WHERE id = $1", task_id)
|
||||
agent = await conn.fetchrow("SELECT * FROM agents WHERE id = $1", agent_id)
|
||||
|
||||
if not task or not agent:
|
||||
raise ValueError("Task or agent not found")
|
||||
|
||||
# Build input context
|
||||
# Prepare input context with serialized datetimes
|
||||
task_for_context = dict(task)
|
||||
for key in ["created_at", "updated_at", "completed_at", "due_date"]:
|
||||
if task_for_context.get(key):
|
||||
task_for_context[key] = (
|
||||
task_for_context[key].isoformat()
|
||||
if hasattr(task_for_context[key], "isoformat")
|
||||
else task_for_context[key]
|
||||
)
|
||||
|
||||
agent_for_context = dict(agent)
|
||||
for key in ["created_at"]:
|
||||
if agent_for_context.get(key):
|
||||
agent_for_context[key] = (
|
||||
agent_for_context[key].isoformat()
|
||||
if hasattr(agent_for_context[key], "isoformat")
|
||||
else agent_for_context[key]
|
||||
)
|
||||
|
||||
input_context = {
|
||||
"task": task_for_context,
|
||||
"agent": agent_for_context,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Check if agent requires approval (CxO level)
|
||||
agent_config = json.loads(agent["config"]) if agent["config"] else {}
|
||||
requires_approval = agent_config.get("approval_level") == "cxo"
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO agent_executions (task_id, agent_id, status, input_context, requires_approval, started_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
""",
|
||||
task_id,
|
||||
agent_id,
|
||||
"pending",
|
||||
json.dumps(input_context),
|
||||
requires_approval,
|
||||
)
|
||||
|
||||
execution = dict(row)
|
||||
execution["input_context"] = json.loads(execution["input_context"]) if execution["input_context"] else {}
|
||||
return execution
|
||||
|
||||
|
||||
async def get_agent_executions(
|
||||
task_id: int | None = None, agent_id: str | None = None, status: str | None = None, limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get agent executions"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
query = "SELECT * FROM agent_executions WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if task_id:
|
||||
params.append(task_id)
|
||||
query += f" AND task_id = ${len(params)}"
|
||||
|
||||
if agent_id:
|
||||
params.append(agent_id)
|
||||
query += f" AND agent_id = ${len(params)}"
|
||||
|
||||
if status:
|
||||
params.append(status)
|
||||
query += f" AND status = ${len(params)}"
|
||||
|
||||
query += " ORDER BY started_at DESC"
|
||||
params.append(limit)
|
||||
query += f" LIMIT ${len(params)}"
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
executions = []
|
||||
for row in rows:
|
||||
execution = dict(row)
|
||||
for json_field in ["input_context", "output_result", "actions_proposed", "actions_executed"]:
|
||||
if execution[json_field]:
|
||||
execution[json_field] = json.loads(execution[json_field])
|
||||
executions.append(execution)
|
||||
return executions
|
||||
|
||||
|
||||
async def get_task_by_id(task_id: int) -> dict[str, Any] | None:
|
||||
"""Get a single task by ID"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT * FROM tasks WHERE id = $1", task_id)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
task = dict(row)
|
||||
task["tags"] = json.loads(task["tags"]) if task["tags"] else []
|
||||
return task
|
||||
|
||||
|
||||
async def get_agent_execution(execution_id: int) -> dict[str, Any] | None:
|
||||
"""Get a single agent execution by ID"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT * FROM agent_executions WHERE id = $1", execution_id)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
execution = dict(row)
|
||||
for json_field in ["input_context", "output_result", "actions_proposed", "actions_executed"]:
|
||||
if execution[json_field]:
|
||||
execution[json_field] = json.loads(execution[json_field])
|
||||
return execution
|
||||
|
||||
|
||||
async def approve_agent_execution(
|
||||
execution_id: int, approved: bool, approved_by: str, feedback: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Approve or reject an agent execution"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Update execution with approval status
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE agent_executions
|
||||
SET approved_by = $1,
|
||||
approved_at = CURRENT_TIMESTAMP,
|
||||
status = CASE WHEN $2 THEN 'approved' ELSE 'rejected' END
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
""",
|
||||
approved_by,
|
||||
approved,
|
||||
execution_id,
|
||||
)
|
||||
|
||||
if not row:
|
||||
raise ValueError(f"Execution {execution_id} not found")
|
||||
|
||||
execution = dict(row)
|
||||
for json_field in ["input_context", "output_result", "actions_proposed", "actions_executed"]:
|
||||
if execution[json_field]:
|
||||
execution[json_field] = json.loads(execution[json_field])
|
||||
|
||||
# If feedback provided, add to output_result
|
||||
if feedback:
|
||||
if not execution["output_result"]:
|
||||
execution["output_result"] = {}
|
||||
execution["output_result"]["approval_feedback"] = feedback
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE agent_executions
|
||||
SET output_result = $1
|
||||
WHERE id = $2
|
||||
""",
|
||||
json.dumps(execution["output_result"]),
|
||||
execution_id,
|
||||
)
|
||||
|
||||
return execution
|
||||
|
||||
|
||||
async def update_agent_config(
|
||||
agent_id: str,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update agent configuration"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Build update query dynamically
|
||||
updates = []
|
||||
params = []
|
||||
param_count = 0
|
||||
|
||||
if name is not None:
|
||||
param_count += 1
|
||||
updates.append(f"name = ${param_count}")
|
||||
params.append(name)
|
||||
|
||||
if description is not None:
|
||||
param_count += 1
|
||||
updates.append(f"description = ${param_count}")
|
||||
params.append(description)
|
||||
|
||||
if system_prompt is not None:
|
||||
param_count += 1
|
||||
updates.append(f"system_prompt = ${param_count}")
|
||||
params.append(system_prompt)
|
||||
|
||||
if config is not None:
|
||||
param_count += 1
|
||||
updates.append(f"config = ${param_count}")
|
||||
params.append(json.dumps(config))
|
||||
|
||||
if enabled is not None:
|
||||
param_count += 1
|
||||
updates.append(f"enabled = ${param_count}")
|
||||
params.append(enabled)
|
||||
|
||||
if not updates:
|
||||
# No updates provided, just return current agent
|
||||
return await get_agent(agent_id)
|
||||
|
||||
param_count += 1
|
||||
params.append(agent_id)
|
||||
|
||||
query = f"UPDATE agents SET {', '.join(updates)} WHERE id = ${param_count} RETURNING *"
|
||||
row = await conn.fetchrow(query, *params)
|
||||
|
||||
if not row:
|
||||
raise ValueError(f"Agent {agent_id} not found")
|
||||
|
||||
agent = dict(row)
|
||||
agent["capabilities"] = json.loads(agent["capabilities"]) if agent["capabilities"] else []
|
||||
agent["config"] = json.loads(agent["config"]) if agent["config"] else {}
|
||||
return agent
|
||||
|
||||
|
||||
async def update_execution_status(
|
||||
execution_id: int,
|
||||
status: str,
|
||||
output_result: dict[str, Any] | None = None,
|
||||
actions_proposed: list[dict[str, Any]] | None = None,
|
||||
actions_executed: list[dict[str, Any]] | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update agent execution status and results"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Check if started_at is already set
|
||||
current = await conn.fetchrow("SELECT started_at FROM agent_executions WHERE id = $1", execution_id)
|
||||
|
||||
updates = ["status = $1"]
|
||||
params = [status]
|
||||
param_count = 1
|
||||
|
||||
# Only set started_at if transitioning to running and not already set
|
||||
if status == "running" and (not current or not current["started_at"]):
|
||||
updates.append("started_at = CURRENT_TIMESTAMP")
|
||||
|
||||
if status in ["completed", "failed", "rejected"]:
|
||||
updates.append("completed_at = CURRENT_TIMESTAMP")
|
||||
|
||||
if output_result is not None:
|
||||
param_count += 1
|
||||
updates.append(f"output_result = ${param_count}")
|
||||
params.append(json.dumps(output_result))
|
||||
|
||||
if actions_proposed is not None:
|
||||
param_count += 1
|
||||
updates.append(f"actions_proposed = ${param_count}")
|
||||
params.append(json.dumps(actions_proposed))
|
||||
|
||||
if actions_executed is not None:
|
||||
param_count += 1
|
||||
updates.append(f"actions_executed = ${param_count}")
|
||||
params.append(json.dumps(actions_executed))
|
||||
|
||||
if error_message is not None:
|
||||
param_count += 1
|
||||
updates.append(f"error_message = ${param_count}")
|
||||
params.append(error_message)
|
||||
|
||||
param_count += 1
|
||||
params.append(execution_id)
|
||||
|
||||
query = f"UPDATE agent_executions SET {', '.join(updates)} WHERE id = ${param_count} RETURNING *"
|
||||
row = await conn.fetchrow(query, *params)
|
||||
|
||||
if not row:
|
||||
raise ValueError(f"Execution {execution_id} not found")
|
||||
|
||||
execution = dict(row)
|
||||
for json_field in ["input_context", "output_result", "actions_proposed", "actions_executed"]:
|
||||
if execution[json_field]:
|
||||
execution[json_field] = json.loads(execution[json_field])
|
||||
return execution
|
||||
+202
-43
@@ -1,28 +1,94 @@
|
||||
from fastapi import FastAPI, Depends, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import traceback as tb
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import docker.errors
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine
|
||||
import auth as auth_module
|
||||
import config
|
||||
from rbac import require_page
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import logging
|
||||
import time
|
||||
import jwt
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT, get_client_ip
|
||||
import auth_service as auth_module
|
||||
import config
|
||||
from config import CORS_ORIGINS, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, get_client_ip
|
||||
from rbac import require_page
|
||||
from routers import auth as auth_router
|
||||
from routers import (
|
||||
cc_connect,
|
||||
chat_summary,
|
||||
conversation_tracker,
|
||||
docker_router,
|
||||
files,
|
||||
gitea,
|
||||
info_engine,
|
||||
litellm,
|
||||
opc_agents,
|
||||
opc_projects,
|
||||
opc_tasks,
|
||||
opc_ws,
|
||||
passkey,
|
||||
security,
|
||||
system,
|
||||
terminal,
|
||||
totp,
|
||||
transmission,
|
||||
)
|
||||
|
||||
_tz_cst = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
print("=== Application Startup ===")
|
||||
|
||||
# Initialize OPC database
|
||||
from db import opc_db
|
||||
|
||||
try:
|
||||
await opc_db.init_db()
|
||||
print("OPC database initialized")
|
||||
except Exception as e:
|
||||
print(f"Failed to initialize OPC database: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# Start agent executor service
|
||||
from services import agent_executor
|
||||
|
||||
executor_task = None
|
||||
try:
|
||||
executor_task = asyncio.create_task(agent_executor.start_executor())
|
||||
print("Agent executor service started")
|
||||
except Exception as e:
|
||||
print(f"Failed to start agent executor: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
monitor_task = asyncio.create_task(monitor_containers())
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
print("=== Application Shutdown ===")
|
||||
if executor_task:
|
||||
executor_task.cancel()
|
||||
monitor_task.cancel()
|
||||
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app = FastAPI(title="NAS Dashboard")
|
||||
app = FastAPI(title="NAS Dashboard", lifespan=lifespan)
|
||||
app.state.limiter = limiter
|
||||
|
||||
app.add_middleware(
|
||||
@@ -36,10 +102,41 @@ app.add_middleware(
|
||||
# Compress responses (static files and API responses)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
return JSONResponse(status_code=429, content={"detail": "Too many requests, try again later"})
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""Handle request validation errors with detailed information."""
|
||||
logging.error(f"Validation error on {request.method} {request.url.path}: {exc.errors()}")
|
||||
return JSONResponse(status_code=422, content={"detail": exc.errors()})
|
||||
|
||||
|
||||
@app.exception_handler(docker.errors.NotFound)
|
||||
async def docker_not_found_handler(request: Request, exc: docker.errors.NotFound):
|
||||
"""Handle Docker container/image not found errors."""
|
||||
logging.warning(f"Docker resource not found: {exc}")
|
||||
return JSONResponse(status_code=404, content={"detail": "Docker resource not found"})
|
||||
|
||||
|
||||
@app.exception_handler(docker.errors.APIError)
|
||||
async def docker_api_error_handler(request: Request, exc: docker.errors.APIError):
|
||||
"""Handle Docker API errors."""
|
||||
logging.error(f"Docker API error: {exc}")
|
||||
return JSONResponse(status_code=503, content={"detail": "Docker service unavailable"})
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def generic_exception_handler(request: Request, exc: Exception):
|
||||
"""Handle all unhandled exceptions."""
|
||||
logging.error(f"Unhandled exception on {request.method} {request.url.path}: {exc}")
|
||||
logging.error(tb.format_exc())
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
@@ -47,7 +144,9 @@ async def security_headers(request: Request, call_next):
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss: ws:; frame-ancestors 'none'"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss: ws:; frame-ancestors 'none'"
|
||||
)
|
||||
|
||||
# Cache static assets with hashed filenames (Vite generates these)
|
||||
if request.url.path.startswith("/assets/"):
|
||||
@@ -55,8 +154,10 @@ async def security_headers(request: Request, call_next):
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# Audit logger
|
||||
import os
|
||||
|
||||
_audit_log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
|
||||
os.makedirs(os.path.dirname(_audit_log_path), exist_ok=True)
|
||||
_audit_logger = logging.getLogger("audit")
|
||||
@@ -66,6 +167,7 @@ _audit_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_audit_logger.addHandler(_audit_handler)
|
||||
_audit_logger.propagate = False
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def audit_log(request: Request, call_next):
|
||||
start = time.time()
|
||||
@@ -75,14 +177,22 @@ async def audit_log(request: Request, call_next):
|
||||
username = user.username if user else "-"
|
||||
_audit_logger.info(
|
||||
"%s %s %s %s %s %d %dms",
|
||||
datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), get_client_ip(request), username,
|
||||
request.method, request.url.path, response.status_code, duration,
|
||||
datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
get_client_ip(request),
|
||||
username,
|
||||
request.method,
|
||||
request.url.path,
|
||||
response.status_code,
|
||||
duration,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
async def monitor_containers():
|
||||
import docker
|
||||
|
||||
from config import DOCKER_HOST
|
||||
|
||||
client = docker.DockerClient(base_url=DOCKER_HOST)
|
||||
|
||||
# Store initial states
|
||||
@@ -90,7 +200,8 @@ async def monitor_containers():
|
||||
|
||||
while True:
|
||||
try:
|
||||
for c in client.containers.list(all=True):
|
||||
containers = await asyncio.to_thread(client.containers.list, all=True)
|
||||
for c in containers:
|
||||
current_status = c.status
|
||||
if c.id in previous_states:
|
||||
prev_status = previous_states[c.id]
|
||||
@@ -111,14 +222,12 @@ async def monitor_containers():
|
||||
|
||||
await asyncio.sleep(60)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(monitor_containers())
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/client-ip")
|
||||
async def client_ip(request: Request):
|
||||
ip = get_client_ip(request)
|
||||
@@ -137,8 +246,10 @@ async def client_ip(request: Request):
|
||||
|
||||
return {"lan": lan}
|
||||
|
||||
|
||||
def _router_reachable():
|
||||
import socket
|
||||
|
||||
try:
|
||||
s = socket.socket()
|
||||
s.settimeout(1)
|
||||
@@ -148,37 +259,85 @@ def _router_reachable():
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Dependency to store user in request.state for rbac dependencies
|
||||
async def _inject_user(request: Request, user = Depends(auth_module.get_current_user)):
|
||||
async def _inject_user(request: Request, user=Depends(auth_module.get_current_user)):
|
||||
request.state.user = user
|
||||
return user
|
||||
|
||||
|
||||
# Public auth endpoints
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(passkey.router, prefix="/api/auth", tags=["passkey"])
|
||||
app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
|
||||
|
||||
# Protected endpoints with page-level RBAC
|
||||
app.include_router(docker_router.router, prefix="/api/docker",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("docker"))])
|
||||
app.include_router(gitea.router, prefix="/api/gitea",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
|
||||
app.include_router(files.router, prefix="/api/files",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("files"))])
|
||||
app.include_router(system.router, prefix="/api/system",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
||||
app.include_router(litellm.router, prefix="/api/litellm",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
||||
app.include_router(cc_connect.router, prefix="/api/cc-connect",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
||||
app.include_router(chat_summary.router, prefix="/api/chat-summary",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))])
|
||||
app.include_router(info_engine.router, prefix="/api/info-engine",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
||||
app.include_router(security.router, prefix="/api/security",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
|
||||
app.include_router(
|
||||
docker_router.router, prefix="/api/docker", dependencies=[Depends(_inject_user), Depends(require_page("docker"))]
|
||||
)
|
||||
app.include_router(
|
||||
gitea.router, prefix="/api/gitea", dependencies=[Depends(_inject_user), Depends(require_page("gitea"))]
|
||||
)
|
||||
app.include_router(
|
||||
files.router, prefix="/api/files", dependencies=[Depends(_inject_user), Depends(require_page("files"))]
|
||||
)
|
||||
app.include_router(
|
||||
system.router, prefix="/api/system", dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]
|
||||
)
|
||||
app.include_router(
|
||||
litellm.router, prefix="/api/litellm", dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]
|
||||
)
|
||||
app.include_router(
|
||||
cc_connect.router,
|
||||
prefix="/api/cc-connect",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))],
|
||||
)
|
||||
app.include_router(
|
||||
chat_summary.router,
|
||||
prefix="/api/chat-summary",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))],
|
||||
)
|
||||
app.include_router(
|
||||
conversation_tracker.router,
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("conversations"))],
|
||||
)
|
||||
app.include_router(
|
||||
info_engine.router,
|
||||
prefix="/api/info-engine",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))],
|
||||
)
|
||||
app.include_router(
|
||||
security.router, prefix="/api/security", dependencies=[Depends(_inject_user), Depends(require_page("security"))]
|
||||
)
|
||||
app.include_router(
|
||||
transmission.router, dependencies=[Depends(_inject_user), Depends(require_page("transmission"))]
|
||||
)
|
||||
|
||||
# OPC endpoints
|
||||
app.include_router(
|
||||
opc_tasks.router, prefix="/api/opc", dependencies=[Depends(_inject_user), Depends(require_page("opc"))]
|
||||
)
|
||||
app.include_router(
|
||||
opc_agents.router, prefix="/api/opc", dependencies=[Depends(_inject_user), Depends(require_page("opc"))]
|
||||
)
|
||||
app.include_router(
|
||||
opc_projects.router, prefix="/api/opc", dependencies=[Depends(_inject_user), Depends(require_page("opc"))]
|
||||
)
|
||||
|
||||
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
|
||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
||||
app.add_api_websocket_route("/ws/opc", opc_ws.websocket_endpoint)
|
||||
|
||||
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||
# Mount static files (skip if directory doesn't exist, e.g., in test environments)
|
||||
import os
|
||||
|
||||
if os.path.isdir("/app/static"):
|
||||
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||
else:
|
||||
# In test environment, create a minimal fallback
|
||||
import tempfile
|
||||
|
||||
_test_static = tempfile.mkdtemp()
|
||||
with open(os.path.join(_test_static, "index.html"), "w") as f:
|
||||
f.write("<html><body>Test Environment</body></html>")
|
||||
app.mount("/", StaticFiles(directory=_test_static, html=True), name="static")
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
OPC Resource-Level Authorization
|
||||
|
||||
Implements fine-grained access control for OPC tasks, agents, and executions.
|
||||
Page-level RBAC (require_page("opc")) controls who can access OPC pages.
|
||||
This module controls which specific resources users can view/modify.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from rbac import User
|
||||
|
||||
|
||||
class OPCAuthz:
|
||||
"""Authorization logic for OPC resources"""
|
||||
|
||||
@staticmethod
|
||||
def can_view_task(user: User, task: dict[str, Any]) -> bool:
|
||||
"""Check if user can view a task"""
|
||||
# Admins see everything
|
||||
if user.role == "admin":
|
||||
return True
|
||||
|
||||
# Users can see tasks they created or are assigned to
|
||||
if task.get("assigned_to") == user.username:
|
||||
return True
|
||||
|
||||
# Check metadata for created_by (will be added in migration)
|
||||
metadata = task.get("metadata", {})
|
||||
if isinstance(metadata, dict) and metadata.get("created_by") == user.username:
|
||||
return True
|
||||
|
||||
# Viewers can see all tasks (read-only enforced elsewhere)
|
||||
if user.role == "viewer":
|
||||
return True
|
||||
|
||||
# Members can see unassigned tasks (parking lot)
|
||||
if task.get("status") == "parking_lot" and not task.get("assigned_to"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def can_modify_task(user: User, task: dict[str, Any]) -> bool:
|
||||
"""Check if user can modify a task"""
|
||||
# Admins can modify everything
|
||||
if user.role == "admin":
|
||||
return True
|
||||
|
||||
# Viewers cannot modify anything
|
||||
if user.role == "viewer":
|
||||
return False
|
||||
|
||||
# Users can modify tasks they created or are assigned to
|
||||
if task.get("assigned_to") == user.username:
|
||||
return True
|
||||
|
||||
metadata = task.get("metadata", {})
|
||||
if isinstance(metadata, dict) and metadata.get("created_by") == user.username:
|
||||
return True
|
||||
|
||||
# Members can modify unassigned tasks
|
||||
if user.role == "member" and task.get("status") == "parking_lot" and not task.get("assigned_to"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def can_delete_task(user: User, task: dict[str, Any]) -> bool:
|
||||
"""Check if user can delete a task"""
|
||||
# Only admins and task creators can delete
|
||||
if user.role == "admin":
|
||||
return True
|
||||
|
||||
metadata = task.get("metadata", {})
|
||||
if isinstance(metadata, dict) and metadata.get("created_by") == user.username:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def can_trigger_agent(user: User, agent_id: str) -> bool:
|
||||
"""Check if user can manually trigger an agent"""
|
||||
# Admins can trigger any agent
|
||||
if user.role == "admin":
|
||||
return True
|
||||
|
||||
# Viewers cannot trigger agents
|
||||
if user.role == "viewer":
|
||||
return False
|
||||
|
||||
# Members can trigger PM agent (auto-approval)
|
||||
# CTO/COO agents require admin (CxO approval level)
|
||||
if agent_id == "pm":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def can_approve_execution(user: User, execution: dict[str, Any]) -> bool:
|
||||
"""Check if user can approve an agent execution"""
|
||||
# Only admins can approve CxO-level executions
|
||||
if user.role == "admin":
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def can_view_agent_execution(user: User, execution: dict[str, Any], task: dict[str, Any] | None = None) -> bool:
|
||||
"""Check if user can view an agent execution"""
|
||||
# Admins see everything
|
||||
if user.role == "admin":
|
||||
return True
|
||||
|
||||
# Users can see executions for tasks they have access to
|
||||
if task and OPCAuthz.can_view_task(user, task):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def filter_tasks(user: User, tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Filter tasks to only those user can view"""
|
||||
if user.role == "admin":
|
||||
return tasks
|
||||
|
||||
return [task for task in tasks if OPCAuthz.can_view_task(user, task)]
|
||||
|
||||
@staticmethod
|
||||
def filter_executions(
|
||||
user: User, executions: list[dict[str, Any]], tasks_by_id: dict[int, dict[str, Any]] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Filter executions to only those user can view"""
|
||||
if user.role == "admin":
|
||||
return executions
|
||||
|
||||
if tasks_by_id is None:
|
||||
# Without task context, only show executions for tasks user is assigned to
|
||||
# This is a conservative filter - caller should provide tasks_by_id for accurate filtering
|
||||
return []
|
||||
|
||||
return [
|
||||
execution
|
||||
for execution in executions
|
||||
if execution.get("task_id") in tasks_by_id
|
||||
and OPCAuthz.can_view_task(user, tasks_by_id[execution["task_id"]])
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def require_task_view(user: User, task: dict[str, Any]):
|
||||
"""Raise 403 if user cannot view task"""
|
||||
if not OPCAuthz.can_view_task(user, task):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
@staticmethod
|
||||
def require_task_modify(user: User, task: dict[str, Any]):
|
||||
"""Raise 403 if user cannot modify task"""
|
||||
if not OPCAuthz.can_modify_task(user, task):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
@staticmethod
|
||||
def require_task_delete(user: User, task: dict[str, Any]):
|
||||
"""Raise 403 if user cannot delete task"""
|
||||
if not OPCAuthz.can_delete_task(user, task):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
@staticmethod
|
||||
def require_agent_trigger(user: User, agent_id: str):
|
||||
"""Raise 403 if user cannot trigger agent"""
|
||||
if not OPCAuthz.can_trigger_agent(user, agent_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
@staticmethod
|
||||
def require_execution_approve(user: User, execution: dict[str, Any]):
|
||||
"""Raise 403 if user cannot approve execution"""
|
||||
if not OPCAuthz.can_approve_execution(user, execution):
|
||||
raise HTTPException(status_code=403, detail="Admin approval required")
|
||||
|
||||
@staticmethod
|
||||
def require_execution_view(user: User, execution: dict[str, Any], task: dict[str, Any] | None = None):
|
||||
"""Raise 403 if user cannot view execution"""
|
||||
if not OPCAuthz.can_view_agent_execution(user, execution, task):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
@@ -0,0 +1,84 @@
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
target-version = ['py312']
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
# directories
|
||||
\.eggs
|
||||
| \.git
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| venv
|
||||
| _build
|
||||
| buck-out
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by black)
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"C901", # too complex
|
||||
"W191", # indentation contains tabs
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401"] # unused imports in __init__.py
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "7.0"
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-v",
|
||||
"--strict-markers",
|
||||
"--tb=short",
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["."]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/venv/*",
|
||||
"*/__pycache__/*",
|
||||
"*/site-packages/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
precision = 2
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if __name__ == .__main__.:",
|
||||
"if TYPE_CHECKING:",
|
||||
"@abstractmethod",
|
||||
]
|
||||
|
||||
[tool.coverage.xml]
|
||||
output = "coverage.xml"
|
||||
+24
-10
@@ -1,26 +1,32 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
import threading
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
import config
|
||||
|
||||
RBAC_FILE = os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json")
|
||||
|
||||
def get_rbac_file():
|
||||
return os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json")
|
||||
|
||||
|
||||
_rbac_lock = threading.RLock()
|
||||
|
||||
ROLE_GROUP_MAP = {
|
||||
"dashboard_admin": "admin",
|
||||
"dashboard_member": "member",
|
||||
"dashboard_viewer": "viewer",
|
||||
"admins": "admin", # Authelia admin group
|
||||
"users": "member", # Authelia user group
|
||||
}
|
||||
|
||||
DEFAULT_RBAC = {
|
||||
"role_defaults": {
|
||||
"admin": {"pages": "*", "sidebar_links": "*"},
|
||||
"member": {"pages": ["dashboard", "docker", "files", "openclaw", "chat-digest"], "sidebar_links": "*"},
|
||||
"member": {"pages": ["dashboard", "docker", "files", "openclaw", "chat-digest", "opc"], "sidebar_links": "*"},
|
||||
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]},
|
||||
},
|
||||
"user_overrides": {},
|
||||
@@ -36,8 +42,9 @@ class User(BaseModel):
|
||||
|
||||
def load_rbac() -> dict:
|
||||
try:
|
||||
if os.path.exists(RBAC_FILE):
|
||||
with open(RBAC_FILE) as f:
|
||||
rbac_path = get_rbac_file()
|
||||
if os.path.exists(rbac_path):
|
||||
with open(rbac_path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -62,15 +69,15 @@ def update_rbac(mutator):
|
||||
with _rbac_lock:
|
||||
data = load_rbac()
|
||||
result = mutator(data)
|
||||
_atomic_json_write(RBAC_FILE, data)
|
||||
_atomic_json_write(get_rbac_file(), data)
|
||||
return result
|
||||
|
||||
|
||||
def save_rbac(data: dict):
|
||||
_atomic_json_write(RBAC_FILE, data)
|
||||
_atomic_json_write(get_rbac_file(), data)
|
||||
|
||||
|
||||
def resolve_role(groups: list[str]) -> Optional[str]:
|
||||
def resolve_role(groups: list[str]) -> str | None:
|
||||
for group in groups:
|
||||
if group in ROLE_GROUP_MAP:
|
||||
return ROLE_GROUP_MAP[group]
|
||||
@@ -102,6 +109,7 @@ def get_sidebar_order(username: str) -> dict | None:
|
||||
def save_sidebar_order(username: str, order: dict):
|
||||
def mutator(rbac):
|
||||
rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order
|
||||
|
||||
update_rbac(mutator)
|
||||
|
||||
|
||||
@@ -113,26 +121,32 @@ def has_page_access(user: User, page: str) -> bool:
|
||||
|
||||
def require_page(page: str):
|
||||
"""FastAPI dependency factory — 403 if user lacks page access."""
|
||||
|
||||
async def checker(request: Request):
|
||||
user: User = request.state.user
|
||||
if not has_page_access(user, page):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
def require_admin():
|
||||
"""FastAPI dependency — 403 if user is not admin."""
|
||||
|
||||
async def checker(request: Request):
|
||||
user: User = request.state.user
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
def require_write():
|
||||
"""FastAPI dependency — 403 if viewer tries non-GET method."""
|
||||
|
||||
async def checker(request: Request):
|
||||
user: User = request.state.user
|
||||
if user.role == "viewer" and request.method != "GET":
|
||||
raise HTTPException(status_code=403, detail="Read-only access")
|
||||
|
||||
return checker
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.24.0
|
||||
pytest-cov==6.0.0
|
||||
pytest-mock==3.14.0
|
||||
pre-commit==4.0.1
|
||||
black==24.10.0
|
||||
ruff==0.8.4
|
||||
@@ -12,3 +12,5 @@ slowapi==0.1.9
|
||||
webauthn==2.7.1
|
||||
cryptography>=44.0.2
|
||||
aiosqlite
|
||||
asyncpg==0.29.0
|
||||
reportlab==4.0.7
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"""Core authentication endpoints: login, token refresh, user info, proxy auth."""
|
||||
from datetime import timedelta
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response
|
||||
import jwt
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
import jwt
|
||||
|
||||
import auth_service as auth
|
||||
import config
|
||||
import auth
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -40,7 +42,7 @@ def _set_auth_response_tokens(response: Response, access_token: str, refresh_tok
|
||||
auth.set_auth_cookies(response, access_token, refresh_token)
|
||||
|
||||
|
||||
def _extract_refresh_token(request: Request, req: Optional[RefreshRequest] = None) -> str:
|
||||
def _extract_refresh_token(request: Request, req: RefreshRequest | None = None) -> str:
|
||||
cookie_token = request.cookies.get(auth.REFRESH_COOKIE_NAME, "")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
@@ -87,7 +89,7 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
|
||||
res = await client.post(
|
||||
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
data={"chat_id": config.TELEGRAM_CHAT_ID, "text": msg, "parse_mode": "Markdown"},
|
||||
timeout=10.0
|
||||
timeout=10.0,
|
||||
)
|
||||
logger.info("Telegram alert sent, status: %s", res.status_code)
|
||||
except Exception as e:
|
||||
@@ -147,6 +149,7 @@ async def proxy_auth(request: Request, response: Response):
|
||||
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
||||
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
||||
from rbac import resolve_role
|
||||
|
||||
if not config.is_trusted_proxy(request.client.host):
|
||||
logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {request.client.host}")
|
||||
raise HTTPException(status_code=403, detail="Unauthorized proxy source")
|
||||
@@ -159,7 +162,9 @@ async def proxy_auth(request: Request, response: Response):
|
||||
remote_groups_raw = request.headers.get("Remote-Groups", "")
|
||||
groups = [g.strip() for g in remote_groups_raw.split(",") if g.strip()]
|
||||
|
||||
logger.info(f"Proxy auth: user={remote_user}, groups={groups}")
|
||||
role = resolve_role(groups)
|
||||
logger.info(f"Resolved role: {role}")
|
||||
if role is None:
|
||||
logger.warning(f"User {remote_user} has no dashboard role (groups: {groups})")
|
||||
raise HTTPException(status_code=403, detail="User not authorized for dashboard")
|
||||
@@ -194,7 +199,7 @@ async def read_users_me(current_user=Depends(auth.get_current_user)):
|
||||
|
||||
@router.post("/refresh")
|
||||
@limiter.limit("10/minute")
|
||||
async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)):
|
||||
async def refresh_token(request: Request, response: Response, req: RefreshRequest | None = Body(default=None)):
|
||||
refresh_token_value = _extract_refresh_token(request, req)
|
||||
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
|
||||
_set_auth_response_tokens(response, access_token, refresh_token)
|
||||
@@ -230,6 +235,7 @@ async def rbac_config(current_user=Depends(auth.get_current_user)):
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import load_rbac
|
||||
|
||||
return load_rbac()
|
||||
|
||||
|
||||
@@ -238,6 +244,7 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import update_rbac
|
||||
|
||||
body = await request.json()
|
||||
pages = body.get("pages")
|
||||
if pages is None:
|
||||
@@ -255,6 +262,7 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend
|
||||
@router.get("/preferences")
|
||||
async def get_preferences(current_user=Depends(auth.get_current_user)):
|
||||
from rbac import get_sidebar_order
|
||||
|
||||
order = get_sidebar_order(current_user.username)
|
||||
return {"sidebar_order": order}
|
||||
|
||||
@@ -262,7 +270,9 @@ async def get_preferences(current_user=Depends(auth.get_current_user)):
|
||||
@router.put("/preferences")
|
||||
async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)):
|
||||
import traceback
|
||||
|
||||
from rbac import save_sidebar_order
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
order = body.get("sidebar_order")
|
||||
@@ -273,9 +283,8 @@ async def save_preferences(request: Request, current_user=Depends(auth.get_curre
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error saving preferences: {e}")
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.exception("Error saving preferences for user %s", current_user.username)
|
||||
raise HTTPException(status_code=500, detail="Failed to save preferences")
|
||||
|
||||
|
||||
@router.delete("/rbac/overrides/{username}")
|
||||
@@ -296,6 +305,7 @@ async def rbac_update_role(role: str, request: Request, current_user=Depends(aut
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
from rbac import update_rbac
|
||||
|
||||
body = await request.json()
|
||||
pages = body.get("pages")
|
||||
sidebar_links = body.get("sidebar_links")
|
||||
|
||||
@@ -7,10 +7,20 @@ from fastapi import APIRouter
|
||||
from config import CC_CONNECT_URL, DOCKER_HOST
|
||||
|
||||
router = APIRouter()
|
||||
client = docker.DockerClient(base_url=DOCKER_HOST, timeout=5)
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_docker_client():
|
||||
"""Get or create Docker client (lazy initialization)."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = docker.DockerClient(base_url=DOCKER_HOST, timeout=5)
|
||||
return _client
|
||||
|
||||
|
||||
def _get_cc_connect_container():
|
||||
client = get_docker_client()
|
||||
matches = client.containers.list(all=True, filters={"name": "cc-connect"})
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import os
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
import auth_service as auth
|
||||
from config import CHAT_SUMMARY_DB
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _db():
|
||||
return aiosqlite.connect(CHAT_SUMMARY_DB)
|
||||
|
||||
|
||||
@router.get("/dates")
|
||||
async def list_dates():
|
||||
async with await _db() as db:
|
||||
cursor = await db.execute("SELECT date FROM summaries ORDER BY date DESC")
|
||||
return [r[0] for r in await cursor.fetchall()]
|
||||
|
||||
|
||||
@router.get("/summary/{date}")
|
||||
async def get_summary(date: str):
|
||||
async with await _db() as db:
|
||||
@@ -22,19 +29,32 @@ async def get_summary(date: str):
|
||||
raise HTTPException(404, "No summary for this date")
|
||||
return {"date": date, "content": row[0], "created_at": row[1]}
|
||||
|
||||
|
||||
@router.get("/messages/{date}")
|
||||
async def get_messages(date: str):
|
||||
async with await _db() as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT group_name, sender_name, text, timestamp FROM messages WHERE date(timestamp)=? ORDER BY timestamp",
|
||||
(date,)
|
||||
(date,),
|
||||
)
|
||||
return [{"group": r[0], "sender": r[1], "text": r[2], "time": r[3]} for r in await cursor.fetchall()]
|
||||
|
||||
|
||||
@router.post("/trigger")
|
||||
async def trigger_summary():
|
||||
import os
|
||||
async def trigger_summary(current_user=Depends(auth.get_current_user)):
|
||||
"""Trigger chat summary generation. Admin only."""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
trigger_path = os.environ.get("CHAT_SUMMARY_TRIGGER", "/app/data/chat-summarizer/trigger")
|
||||
|
||||
# Validate path to prevent directory traversal to sensitive locations
|
||||
trigger_path = os.path.abspath(trigger_path)
|
||||
# Block access to system directories
|
||||
forbidden_prefixes = ("/etc/", "/root/", "/sys/", "/proc/", "/boot/")
|
||||
if any(trigger_path.startswith(prefix) for prefix in forbidden_prefixes):
|
||||
raise HTTPException(status_code=400, detail="Invalid trigger path")
|
||||
|
||||
os.makedirs(os.path.dirname(trigger_path), exist_ok=True)
|
||||
with open(trigger_path, "w") as f:
|
||||
f.write("1")
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import os
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter(prefix="/api/conversations", tags=["conversations"])
|
||||
|
||||
CONVERSATION_TRACKER_DB = os.environ.get("CONVERSATION_TRACKER_DB", "/app/data/claude-code-tracker/conversations.db")
|
||||
|
||||
|
||||
async def _db():
|
||||
return aiosqlite.connect(CONVERSATION_TRACKER_DB)
|
||||
|
||||
|
||||
@router.get("/dates")
|
||||
async def list_dates():
|
||||
"""List all dates with conversations"""
|
||||
async with await _db() as db:
|
||||
cursor = await db.execute("""
|
||||
SELECT DISTINCT DATE(started_at) as date
|
||||
FROM conversations
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
return [r[0] for r in await cursor.fetchall()]
|
||||
|
||||
|
||||
@router.get("/summary/{date}")
|
||||
async def get_summary(date: str):
|
||||
"""Get daily summary for a date"""
|
||||
async with await _db() as db:
|
||||
cursor = await db.execute("""
|
||||
SELECT summary_content, conversation_count, total_messages,
|
||||
total_tokens, created_at, project_path
|
||||
FROM daily_summaries
|
||||
WHERE date = ?
|
||||
""", (date,))
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "No summary for this date")
|
||||
|
||||
return {
|
||||
"date": date,
|
||||
"content": row[0],
|
||||
"conversation_count": row[1],
|
||||
"total_messages": row[2],
|
||||
"total_tokens": row[3],
|
||||
"created_at": row[4],
|
||||
"projects": row[5]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_conversations(
|
||||
date: Optional[str] = None,
|
||||
project_path: Optional[str] = None,
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = 0
|
||||
):
|
||||
"""List conversations with filters"""
|
||||
async with await _db() as db:
|
||||
query = "SELECT session_id, project_path, slug, started_at, message_count, total_input_tokens, total_output_tokens, model FROM conversations WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if date:
|
||||
query += " AND DATE(started_at) = ?"
|
||||
params.append(date)
|
||||
|
||||
if project_path:
|
||||
query += " AND project_path LIKE ?"
|
||||
params.append(f"%{project_path}%")
|
||||
|
||||
query += " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor = await db.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return [{
|
||||
"session_id": r[0],
|
||||
"project_path": r[1],
|
||||
"slug": r[2],
|
||||
"started_at": r[3],
|
||||
"message_count": r[4],
|
||||
"total_tokens": r[5] + r[6],
|
||||
"model": r[7]
|
||||
} for r in rows]
|
||||
|
||||
|
||||
@router.get("/{session_id}")
|
||||
async def get_conversation(session_id: str):
|
||||
"""Get conversation details"""
|
||||
async with await _db() as db:
|
||||
# Get conversation metadata
|
||||
cursor = await db.execute("""
|
||||
SELECT project_path, git_branch, slug, started_at, last_updated_at,
|
||||
message_count, total_input_tokens, total_output_tokens, model
|
||||
FROM conversations
|
||||
WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
conv = await cursor.fetchone()
|
||||
|
||||
if not conv:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
|
||||
# Get message count
|
||||
cursor = await db.execute("""
|
||||
SELECT COUNT(*) FROM messages WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
msg_count = (await cursor.fetchone())[0]
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"project_path": conv[0],
|
||||
"git_branch": conv[1],
|
||||
"slug": conv[2],
|
||||
"started_at": conv[3],
|
||||
"last_updated_at": conv[4],
|
||||
"message_count": msg_count,
|
||||
"total_input_tokens": conv[6],
|
||||
"total_output_tokens": conv[7],
|
||||
"model": conv[8]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{session_id}/messages")
|
||||
async def get_messages(
|
||||
session_id: str,
|
||||
limit: int = Query(100, le=500),
|
||||
offset: int = 0
|
||||
):
|
||||
"""Get messages for a conversation"""
|
||||
async with await _db() as db:
|
||||
cursor = await db.execute("""
|
||||
SELECT message_uuid, role, content_text, timestamp, model,
|
||||
input_tokens, output_tokens, has_tool_use, has_thinking
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp
|
||||
LIMIT ? OFFSET ?
|
||||
""", (session_id, limit, offset))
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
messages = []
|
||||
for r in rows:
|
||||
msg = {
|
||||
"uuid": r[0],
|
||||
"role": r[1],
|
||||
"content": r[2],
|
||||
"timestamp": r[3],
|
||||
"model": r[4],
|
||||
"input_tokens": r[5],
|
||||
"output_tokens": r[6],
|
||||
"has_tool_use": bool(r[7]),
|
||||
"has_thinking": bool(r[8]),
|
||||
"tool_calls": []
|
||||
}
|
||||
|
||||
# Get tool calls for this message
|
||||
if msg["has_tool_use"]:
|
||||
cursor2 = await db.execute("""
|
||||
SELECT tool_name, tool_input, tool_result, is_error
|
||||
FROM tool_calls
|
||||
WHERE message_uuid = ?
|
||||
""", (r[0],))
|
||||
tool_rows = await cursor2.fetchall()
|
||||
msg["tool_calls"] = [{
|
||||
"name": tr[0],
|
||||
"input": tr[1],
|
||||
"result": tr[2],
|
||||
"is_error": bool(tr[3])
|
||||
} for tr in tool_rows]
|
||||
|
||||
messages.append(msg)
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_conversations(
|
||||
q: str = Query(..., min_length=2),
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
limit: int = Query(50, le=200)
|
||||
):
|
||||
"""Search conversations by text"""
|
||||
async with await _db() as db:
|
||||
query = """
|
||||
SELECT DISTINCT m.session_id, c.project_path, c.slug, c.started_at,
|
||||
c.message_count, c.total_input_tokens, c.total_output_tokens
|
||||
FROM messages m
|
||||
JOIN conversations c ON m.session_id = c.session_id
|
||||
WHERE m.content_text LIKE ?
|
||||
"""
|
||||
params = [f"%{q}%"]
|
||||
|
||||
if date_from:
|
||||
query += " AND DATE(c.started_at) >= ?"
|
||||
params.append(date_from)
|
||||
|
||||
if date_to:
|
||||
query += " AND DATE(c.started_at) <= ?"
|
||||
params.append(date_to)
|
||||
|
||||
query += " ORDER BY c.started_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor = await db.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return [{
|
||||
"session_id": r[0],
|
||||
"project_path": r[1],
|
||||
"slug": r[2],
|
||||
"started_at": r[3],
|
||||
"message_count": r[4],
|
||||
"total_tokens": r[5] + r[6]
|
||||
} for r in rows]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None
|
||||
):
|
||||
"""Get overall statistics"""
|
||||
async with await _db() as db:
|
||||
query = "SELECT COUNT(*), SUM(message_count), SUM(total_input_tokens + total_output_tokens) FROM conversations WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if date_from:
|
||||
query += " AND DATE(started_at) >= ?"
|
||||
params.append(date_from)
|
||||
|
||||
if date_to:
|
||||
query += " AND DATE(started_at) <= ?"
|
||||
params.append(date_to)
|
||||
|
||||
cursor = await db.execute(query, params)
|
||||
stats = await cursor.fetchone()
|
||||
|
||||
# Get top projects
|
||||
cursor = await db.execute("""
|
||||
SELECT project_path, COUNT(*) as count
|
||||
FROM conversations
|
||||
GROUP BY project_path
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
projects = await cursor.fetchall()
|
||||
|
||||
# Get tool usage
|
||||
cursor = await db.execute("""
|
||||
SELECT tool_name, COUNT(*) as count
|
||||
FROM tool_calls
|
||||
GROUP BY tool_name
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
tools = await cursor.fetchall()
|
||||
|
||||
return {
|
||||
"total_conversations": stats[0] or 0,
|
||||
"total_messages": stats[1] or 0,
|
||||
"total_tokens": stats[2] or 0,
|
||||
"top_projects": [{"path": p[0], "count": p[1]} for p in projects],
|
||||
"top_tools": [{"name": t[0], "count": t[1]} for t in tools]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/trigger")
|
||||
async def trigger_summary():
|
||||
"""Trigger manual summary generation"""
|
||||
trigger_path = os.environ.get("TRIGGER_PATH", "/app/data/claude-code-tracker/trigger")
|
||||
|
||||
# Validate path
|
||||
trigger_path = os.path.abspath(trigger_path)
|
||||
forbidden_prefixes = ("/etc/", "/root/", "/sys/", "/proc/", "/boot/")
|
||||
if any(trigger_path.startswith(prefix) for prefix in forbidden_prefixes):
|
||||
raise HTTPException(status_code=400, detail="Invalid trigger path")
|
||||
|
||||
os.makedirs(os.path.dirname(trigger_path), exist_ok=True)
|
||||
with open(trigger_path, "w") as f:
|
||||
f.write("1")
|
||||
|
||||
return {"status": "triggered"}
|
||||
@@ -1,14 +1,25 @@
|
||||
import docker
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from config import DOCKER_HOST
|
||||
from rbac import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
client = docker.DockerClient(base_url=DOCKER_HOST)
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_docker_client():
|
||||
"""Get or create Docker client (lazy initialization)."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = docker.DockerClient(base_url=DOCKER_HOST)
|
||||
return _client
|
||||
|
||||
|
||||
@router.get("/containers")
|
||||
def list_containers():
|
||||
client = get_docker_client()
|
||||
return [
|
||||
{
|
||||
"id": c.short_id,
|
||||
@@ -25,7 +36,8 @@ def list_containers():
|
||||
@router.post("/containers/{container_id}/{action}", dependencies=[Depends(require_admin())])
|
||||
def container_action(container_id: str, action: str):
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return {"error": "invalid action"}
|
||||
raise HTTPException(status_code=400, detail="Invalid action. Must be one of: start, stop, restart")
|
||||
client = get_docker_client()
|
||||
c = client.containers.get(container_id)
|
||||
getattr(c, action)()
|
||||
return {"ok": True}
|
||||
@@ -33,5 +45,6 @@ def container_action(container_id: str, action: str):
|
||||
|
||||
@router.get("/containers/{container_id}/logs")
|
||||
def container_logs(container_id: str, tail: int = Query(200, le=10000)):
|
||||
client = get_docker_client()
|
||||
c = client.containers.get(container_id)
|
||||
return {"logs": c.logs(tail=tail, timestamps=True).decode(errors="replace")}
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import VOLUME_ROOT
|
||||
from rbac import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
BASE = Path(VOLUME_ROOT)
|
||||
|
||||
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
|
||||
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker", "homes", "backups"}
|
||||
|
||||
# Download token storage: {token: (path, expiry_timestamp)}
|
||||
_download_tokens = {}
|
||||
TOKEN_EXPIRY = 300 # 5 minutes
|
||||
|
||||
|
||||
_BASE_RESOLVED = str(BASE.resolve())
|
||||
|
||||
|
||||
def _safe_path(path: str) -> Path:
|
||||
# Strip leading slashes to prevent absolute path interpretation
|
||||
path = path.lstrip("/")
|
||||
target = (BASE / path).resolve()
|
||||
if not str(target).startswith(_BASE_RESOLVED):
|
||||
raise ValueError("forbidden")
|
||||
@@ -47,7 +59,7 @@ def _is_safe_symlink(item: Path) -> bool:
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
name = os.path.basename(name)
|
||||
name = name.replace("\x00", "").replace("/", "").replace("\\", "")
|
||||
name = re.sub(r'\.\.+', '.', name)
|
||||
name = re.sub(r"\.\.+", ".", name)
|
||||
name = name.strip(". ")
|
||||
if not name:
|
||||
raise ValueError("invalid filename")
|
||||
@@ -60,8 +72,18 @@ def browse(path: str = ""):
|
||||
target = _safe_path(path)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Check if we have permission to read the directory
|
||||
try:
|
||||
items = list(target.iterdir())
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
except OSError as e:
|
||||
logger.error(f"Error reading directory {target}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Error reading directory")
|
||||
|
||||
entries = []
|
||||
for item in sorted(target.iterdir()):
|
||||
for item in sorted(items):
|
||||
try:
|
||||
if not _is_safe_symlink(item):
|
||||
continue
|
||||
@@ -72,10 +94,65 @@ def browse(path: str = ""):
|
||||
return {"path": str(target.relative_to(BASE)), "entries": entries}
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
def download(path: str):
|
||||
@router.post("/download-token")
|
||||
def create_download_token(path: str):
|
||||
"""Generate a temporary download token for large file downloads."""
|
||||
try:
|
||||
return FileResponse(_safe_path(path))
|
||||
target = _safe_path(path)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Clean up expired tokens
|
||||
now = time.time()
|
||||
expired = [t for t, (_, exp) in _download_tokens.items() if exp < now]
|
||||
for t in expired:
|
||||
del _download_tokens[t]
|
||||
|
||||
# Generate new token
|
||||
token = secrets.token_urlsafe(32)
|
||||
_download_tokens[token] = (str(target), now + TOKEN_EXPIRY)
|
||||
|
||||
return {"token": token, "expires_in": TOKEN_EXPIRY}
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
def download(path: str = None, token: str = Query(None)):
|
||||
"""Download a file. Supports both authenticated access (path param) and token-based access (token param)."""
|
||||
try:
|
||||
if token:
|
||||
# Token-based download (no auth required)
|
||||
if token not in _download_tokens:
|
||||
raise HTTPException(status_code=403, detail="Invalid or expired token")
|
||||
|
||||
file_path, expiry = _download_tokens[token]
|
||||
if time.time() > expiry:
|
||||
del _download_tokens[token]
|
||||
raise HTTPException(status_code=403, detail="Token expired")
|
||||
|
||||
# Consume token after use
|
||||
del _download_tokens[token]
|
||||
target = Path(file_path)
|
||||
else:
|
||||
# Authenticated download
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="Path or token required")
|
||||
target = _safe_path(path)
|
||||
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
def file_iterator():
|
||||
with open(target, "rb") as f:
|
||||
while chunk := f.read(8192 * 1024): # 8MB chunks
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
file_iterator(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{target.name}"'}
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
@@ -124,5 +201,6 @@ def delete(path: str, recursive: bool = False):
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
logger.exception("OS error during file deletion: %s", path)
|
||||
raise HTTPException(status_code=400, detail="Failed to delete file")
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from config import GITEA_URL, GITEA_TOKEN
|
||||
|
||||
from config import GITEA_TOKEN, GITEA_URL
|
||||
|
||||
router = APIRouter()
|
||||
HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
|
||||
_SAFE_NAME = re.compile(r'^[a-zA-Z0-9._-]+$')
|
||||
_SAFE_NAME = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
|
||||
|
||||
@router.get("/repos")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from config import LITELLM_URL, LITELLM_HEALTH_API_KEY
|
||||
|
||||
from config import LITELLM_HEALTH_API_KEY, LITELLM_URL
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
OPC Agents Router
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from db import opc_db
|
||||
from opc_authz import OPCAuthz
|
||||
from rbac import User, require_admin
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
system_prompt: str | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ExecutionApproval(BaseModel):
|
||||
approved: bool
|
||||
feedback: str | None = None
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def list_agents(
|
||||
request: Request,
|
||||
enabled_only: bool = True,
|
||||
):
|
||||
"""List all agents"""
|
||||
user: User = request.state.user
|
||||
agents = await opc_db.get_agents(enabled_only=enabled_only)
|
||||
return {"items": agents}
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
async def get_agent(
|
||||
agent_id: str,
|
||||
request: Request,
|
||||
):
|
||||
"""Get agent details"""
|
||||
user: User = request.state.user
|
||||
agent = await opc_db.get_agent(agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return agent
|
||||
|
||||
|
||||
@router.put("/agents/{agent_id}", dependencies=[Depends(require_admin())])
|
||||
async def update_agent(
|
||||
agent_id: str,
|
||||
updates: AgentUpdate,
|
||||
request: Request,
|
||||
):
|
||||
"""Update agent configuration (admin only)"""
|
||||
user: User = request.state.user
|
||||
try:
|
||||
agent = await opc_db.update_agent_config(
|
||||
agent_id=agent_id,
|
||||
name=updates.name,
|
||||
description=updates.description,
|
||||
system_prompt=updates.system_prompt,
|
||||
enabled=updates.enabled,
|
||||
)
|
||||
return agent
|
||||
except ValueError as e:
|
||||
logger.exception("Failed to update agent %s", agent_id)
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
|
||||
@router.post("/agents/{agent_id}/execute")
|
||||
async def trigger_agent(
|
||||
agent_id: str,
|
||||
task_id: int,
|
||||
request: Request,
|
||||
):
|
||||
"""Manually trigger agent execution"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Check if user can trigger this agent
|
||||
OPCAuthz.require_agent_trigger(user, agent_id)
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check if user can modify this task
|
||||
OPCAuthz.require_task_modify(user, task)
|
||||
|
||||
execution = await opc_db.create_agent_execution(task_id=task_id, agent_id=agent_id, actor=user.username)
|
||||
return execution
|
||||
|
||||
|
||||
@router.get("/executions")
|
||||
async def list_executions(
|
||||
request: Request,
|
||||
task_id: int | None = None,
|
||||
agent_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
):
|
||||
"""List agent executions"""
|
||||
user: User = request.state.user
|
||||
executions = await opc_db.get_agent_executions(task_id=task_id, agent_id=agent_id, status=status, limit=limit)
|
||||
|
||||
# Get all tasks for filtering
|
||||
if user.role != "admin":
|
||||
# Fetch tasks to check authorization
|
||||
task_ids = list(set(e["task_id"] for e in executions))
|
||||
tasks = []
|
||||
for tid in task_ids:
|
||||
task = await opc_db.get_task_by_id(tid)
|
||||
if task:
|
||||
tasks.append(task)
|
||||
tasks_by_id = {t["id"]: t for t in tasks}
|
||||
executions = OPCAuthz.filter_executions(user, executions, tasks_by_id)
|
||||
|
||||
return {"items": executions}
|
||||
|
||||
|
||||
@router.get("/executions/{execution_id}")
|
||||
async def get_execution(
|
||||
execution_id: int,
|
||||
request: Request,
|
||||
):
|
||||
"""Get execution details"""
|
||||
user: User = request.state.user
|
||||
execution = await opc_db.get_agent_execution(execution_id)
|
||||
if not execution:
|
||||
raise HTTPException(status_code=404, detail="Execution not found")
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(execution["task_id"])
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_execution_view(user, execution, task)
|
||||
|
||||
return execution
|
||||
|
||||
|
||||
@router.post("/executions/{execution_id}/approve")
|
||||
async def approve_execution(
|
||||
execution_id: int,
|
||||
approval: ExecutionApproval,
|
||||
request: Request,
|
||||
):
|
||||
"""Approve or reject agent execution (for CxO level actions)"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Get execution
|
||||
execution = await opc_db.get_agent_execution(execution_id)
|
||||
if not execution:
|
||||
raise HTTPException(status_code=404, detail="Execution not found")
|
||||
|
||||
# Check authorization (only admins can approve)
|
||||
OPCAuthz.require_execution_approve(user, execution)
|
||||
|
||||
try:
|
||||
execution = await opc_db.approve_agent_execution(
|
||||
execution_id=execution_id, approved=approval.approved, approved_by=user.username, feedback=approval.feedback
|
||||
)
|
||||
return execution
|
||||
except ValueError as e:
|
||||
logger.exception("Failed to approve agent execution %s", execution_id)
|
||||
raise HTTPException(status_code=404, detail="Execution not found")
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
OPC Projects and Invoicing Router
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from db import opc_db
|
||||
from rbac import User
|
||||
from services import pdf_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
client_id: int | None = None
|
||||
|
||||
|
||||
class InvoiceGenerate(BaseModel):
|
||||
project_id: int
|
||||
client_id: int
|
||||
invoice_number: str | None = None
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
async def list_projects(
|
||||
request: Request,
|
||||
):
|
||||
"""List all projects"""
|
||||
user: User = request.state.user
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("SELECT * FROM projects ORDER BY created_at DESC")
|
||||
return {"items": [dict(row) for row in rows]}
|
||||
|
||||
|
||||
@router.post("/projects")
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
request: Request,
|
||||
):
|
||||
"""Create a new project"""
|
||||
user: User = request.state.user
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO projects (name, description, client_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
""",
|
||||
project.name,
|
||||
project.description,
|
||||
project.client_id,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/clients")
|
||||
async def list_clients(
|
||||
request: Request,
|
||||
):
|
||||
"""List all clients"""
|
||||
user: User = request.state.user
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("SELECT * FROM clients ORDER BY name")
|
||||
return {"items": [dict(row) for row in rows]}
|
||||
|
||||
|
||||
@router.post("/clients")
|
||||
async def create_client(
|
||||
request: Request,
|
||||
name: str,
|
||||
email: str | None = None,
|
||||
company: str | None = None,
|
||||
):
|
||||
"""Create a new client"""
|
||||
user: User = request.state.user
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO clients (name, email, company)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
""",
|
||||
name,
|
||||
email,
|
||||
company,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.post("/invoices/generate")
|
||||
async def generate_invoice(
|
||||
invoice: InvoiceGenerate,
|
||||
request: Request,
|
||||
):
|
||||
"""Generate PDF invoice for a project"""
|
||||
user: User = request.state.user
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Get client
|
||||
client_row = await conn.fetchrow("SELECT * FROM clients WHERE id = $1", invoice.client_id)
|
||||
if not client_row:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
client = dict(client_row)
|
||||
|
||||
# Get time entries for project
|
||||
time_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM time_entries
|
||||
WHERE project_id = $1 AND billable = TRUE
|
||||
ORDER BY started_at
|
||||
""",
|
||||
invoice.project_id,
|
||||
)
|
||||
|
||||
if not time_rows:
|
||||
raise HTTPException(status_code=400, detail="No billable time entries found")
|
||||
|
||||
time_entries = [dict(row) for row in time_rows]
|
||||
|
||||
# Generate PDF
|
||||
pdf_bytes = pdf_service.generate_invoice_from_project(
|
||||
project_id=invoice.project_id,
|
||||
client=client,
|
||||
time_entries=time_entries,
|
||||
invoice_number=invoice.invoice_number,
|
||||
)
|
||||
|
||||
# Return PDF
|
||||
filename = f"invoice-{invoice.invoice_number or datetime.now().strftime('%Y%m%d')}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/time")
|
||||
async def get_project_time(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
):
|
||||
"""Get time entries for a project"""
|
||||
user: User = request.state.user
|
||||
entries = await opc_db.get_time_entries(project_id=project_id)
|
||||
total_minutes = sum(e["duration_minutes"] for e in entries)
|
||||
billable_minutes = sum(e["duration_minutes"] for e in entries if e.get("billable"))
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"total_minutes": total_minutes,
|
||||
"total_hours": round(total_minutes / 60, 2),
|
||||
"billable_minutes": billable_minutes,
|
||||
"billable_hours": round(billable_minutes / 60, 2),
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
OPC Task Management Router
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from db import opc_db
|
||||
from opc_authz import OPCAuthz
|
||||
from rbac import User
|
||||
from routers import opc_ws
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: str = "parking_lot"
|
||||
priority: str = "medium"
|
||||
project_id: int | None = None
|
||||
assigned_to: str | None = None
|
||||
assigned_type: str = "human"
|
||||
tags: list[str] | None = None
|
||||
due_date: datetime | None = None
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
priority: str | None = None
|
||||
project_id: int | None = None
|
||||
assigned_to: str | None = None
|
||||
assigned_type: str | None = None
|
||||
tags: list[str] | None = None
|
||||
due_date: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskMove(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class TaskAssign(BaseModel):
|
||||
assigned_to: str
|
||||
assigned_type: str = "human"
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(
|
||||
request: Request,
|
||||
status: str | None = Query(None),
|
||||
assigned_to: str | None = Query(None),
|
||||
project_id: int | None = Query(None),
|
||||
priority: str | None = Query(None),
|
||||
tags: str | None = Query(None),
|
||||
limit: int = Query(50, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List tasks with filters"""
|
||||
user: User = request.state.user
|
||||
tag_list = tags.split(",") if tags else None
|
||||
tasks = await opc_db.get_tasks(
|
||||
status=status,
|
||||
assigned_to=assigned_to,
|
||||
project_id=project_id,
|
||||
priority=priority,
|
||||
tags=tag_list,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
# Filter tasks based on user permissions
|
||||
filtered_tasks = OPCAuthz.filter_tasks(user, tasks)
|
||||
return {"items": filtered_tasks, "total": len(filtered_tasks)}
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
async def create_task(
|
||||
request: Request,
|
||||
task: TaskCreate,
|
||||
):
|
||||
"""Create a new task"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Viewers cannot create tasks
|
||||
if user.role == "viewer":
|
||||
raise HTTPException(status_code=403, detail="Read-only access")
|
||||
|
||||
# Strip timezone info from due_date if present (database uses TIMESTAMP not TIMESTAMPTZ)
|
||||
due_date = task.due_date.replace(tzinfo=None) if task.due_date else None
|
||||
created_task = await opc_db.create_task(
|
||||
title=task.title,
|
||||
description=task.description,
|
||||
status=task.status,
|
||||
priority=task.priority,
|
||||
project_id=task.project_id,
|
||||
assigned_to=task.assigned_to,
|
||||
assigned_type=task.assigned_type,
|
||||
tags=task.tags,
|
||||
due_date=due_date,
|
||||
actor=user.username,
|
||||
)
|
||||
|
||||
# Track creator in metadata for authorization
|
||||
metadata = created_task.get("metadata", {})
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
metadata["created_by"] = user.username
|
||||
await opc_db.update_task(task_id=created_task["id"], updates={"metadata": metadata}, actor=user.username)
|
||||
created_task["metadata"] = metadata
|
||||
|
||||
# Start automatic timer if status is in_progress
|
||||
if task.status == "in_progress":
|
||||
await opc_db.start_time_entry(task_id=created_task["id"], project_id=task.project_id, actor=user.username)
|
||||
|
||||
# Broadcast WebSocket update
|
||||
await opc_ws.broadcast_task_update(created_task["id"], "created", created_task)
|
||||
|
||||
return created_task
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
):
|
||||
"""Get task details"""
|
||||
user: User = request.state.user
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_view(user, task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.put("/tasks/{task_id}")
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
task: TaskUpdate,
|
||||
request: Request,
|
||||
):
|
||||
"""Update a task"""
|
||||
user: User = request.state.user
|
||||
updates = task.dict(exclude_unset=True)
|
||||
|
||||
# Get current task
|
||||
current_task = await opc_db.get_task_by_id(task_id)
|
||||
if not current_task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_modify(user, current_task)
|
||||
|
||||
# Handle automatic time tracking
|
||||
old_status = current_task["status"]
|
||||
new_status = updates.get("status", old_status)
|
||||
|
||||
# Start timer when moving to in_progress
|
||||
if old_status != "in_progress" and new_status == "in_progress":
|
||||
await opc_db.start_time_entry(task_id=task_id, project_id=current_task.get("project_id"), actor=user.username)
|
||||
|
||||
# Stop timer when moving away from in_progress
|
||||
if old_status == "in_progress" and new_status != "in_progress":
|
||||
await opc_db.stop_time_entry(task_id=task_id)
|
||||
|
||||
# Set completed_at when moving to done
|
||||
if new_status == "done" and old_status != "done":
|
||||
updates["completed_at"] = datetime.utcnow()
|
||||
|
||||
# Strip timezone info from datetime fields (database uses TIMESTAMP not TIMESTAMPTZ)
|
||||
if "due_date" in updates and updates["due_date"] is not None:
|
||||
updates["due_date"] = updates["due_date"].replace(tzinfo=None)
|
||||
if "completed_at" in updates and updates["completed_at"] is not None:
|
||||
updates["completed_at"] = updates["completed_at"].replace(tzinfo=None)
|
||||
|
||||
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||
|
||||
# Broadcast WebSocket update
|
||||
await opc_ws.broadcast_task_update(task_id, "updated", updated_task)
|
||||
|
||||
return updated_task
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}")
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
):
|
||||
"""Delete a task"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_delete(user, task)
|
||||
|
||||
await opc_db.delete_task(task_id=task_id, actor=user.username)
|
||||
|
||||
# Broadcast WebSocket update
|
||||
await opc_ws.broadcast_task_update(task_id, "deleted", {"id": task_id})
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/tasks/{task_id}/move")
|
||||
async def move_task(
|
||||
task_id: int,
|
||||
move: TaskMove,
|
||||
request: Request,
|
||||
):
|
||||
"""Move task to a different column"""
|
||||
print(f"[Move] move_task called: task_id={task_id}, new_status={move.status}")
|
||||
user: User = request.state.user
|
||||
updates = {"status": move.status}
|
||||
|
||||
# Get current task
|
||||
current_task = await opc_db.get_task_by_id(task_id)
|
||||
if not current_task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_modify(user, current_task)
|
||||
|
||||
old_status = current_task["status"]
|
||||
print(
|
||||
f"[Move] Task {task_id}: old_status={old_status}, new_status={move.status}, assigned_to={current_task.get('assigned_to')}"
|
||||
)
|
||||
|
||||
# Handle automatic time tracking
|
||||
if old_status != "in_progress" and move.status == "in_progress":
|
||||
print(f"[Move] Task {task_id} moving to in_progress, old_status={old_status}")
|
||||
await opc_db.start_time_entry(task_id=task_id, project_id=current_task.get("project_id"), actor=user.username)
|
||||
|
||||
# Auto-assign to default agent (PM) if not already assigned
|
||||
if not current_task.get("assigned_to"):
|
||||
print(f"[Move] Task {task_id} not assigned, auto-assigning to PM agent")
|
||||
updates["assigned_to"] = "pm"
|
||||
updates["assigned_type"] = "agent"
|
||||
|
||||
# If assigned to an agent, ensure there's a pending execution
|
||||
if current_task.get("assigned_type") == "agent" or updates.get("assigned_type") == "agent":
|
||||
agent_id = updates.get("assigned_to") or current_task.get("assigned_to")
|
||||
print(f"[Move] Task {task_id} assigned to agent {agent_id}, checking for existing execution")
|
||||
|
||||
# Check if there's already a pending execution
|
||||
existing_executions = await opc_db.get_agent_executions(task_id=task_id, status="pending", limit=1)
|
||||
|
||||
if not existing_executions:
|
||||
print(f"[Move] No pending execution found, creating one for agent {agent_id}")
|
||||
execution_id = await opc_db.create_agent_execution(
|
||||
task_id=task_id, agent_id=agent_id, actor=user.username
|
||||
)
|
||||
print(f"[Move] Created agent execution {execution_id} for task {task_id}")
|
||||
else:
|
||||
print(f"[Move] Pending execution already exists: {existing_executions[0]['id']}")
|
||||
|
||||
if old_status == "in_progress" and move.status != "in_progress":
|
||||
await opc_db.stop_time_entry(task_id=task_id)
|
||||
|
||||
if move.status == "done":
|
||||
updates["completed_at"] = datetime.utcnow().replace(tzinfo=None)
|
||||
|
||||
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||
return updated_task
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/assign")
|
||||
async def assign_task(
|
||||
task_id: int,
|
||||
assign: TaskAssign,
|
||||
request: Request,
|
||||
):
|
||||
"""Assign task to human or agent"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_modify(user, task)
|
||||
|
||||
updates = {"assigned_to": assign.assigned_to, "assigned_type": assign.assigned_type}
|
||||
|
||||
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||
|
||||
# If assigning to agent, create execution record
|
||||
if assign.assigned_type == "agent":
|
||||
await opc_db.create_agent_execution(task_id=task_id, agent_id=assign.assigned_to, actor=user.username)
|
||||
|
||||
return updated_task
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/history")
|
||||
async def get_task_history(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
):
|
||||
"""Get task history"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_view(user, task)
|
||||
|
||||
history = await opc_db.get_task_history(task_id=task_id)
|
||||
return {"items": history}
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/time")
|
||||
async def get_task_time(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
):
|
||||
"""Get time entries for a task"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_view(user, task)
|
||||
|
||||
entries = await opc_db.get_time_entries(task_id=task_id)
|
||||
total_minutes = sum(e["duration_minutes"] for e in entries)
|
||||
return {"entries": entries, "total_minutes": total_minutes, "total_hours": round(total_minutes / 60, 2)}
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
OPC WebSocket Router - Real-time updates for tasks and agent executions
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Active WebSocket connections
|
||||
active_connections: set[WebSocket] = set()
|
||||
|
||||
|
||||
def serialize_datetime(obj: Any) -> Any:
|
||||
"""Recursively serialize datetime objects to ISO format strings"""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
elif isinstance(obj, dict):
|
||||
return {key: serialize_datetime(value) for key, value in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [serialize_datetime(item) for item in obj]
|
||||
else:
|
||||
return obj
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: set[WebSocket] = set()
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
"""Accept and register a new connection"""
|
||||
await websocket.accept()
|
||||
self.active_connections.add(websocket)
|
||||
logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
"""Remove a connection"""
|
||||
self.active_connections.discard(websocket)
|
||||
logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Broadcast message to all connected clients"""
|
||||
disconnected = set()
|
||||
for connection in self.active_connections:
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send message: {e}")
|
||||
disconnected.add(connection)
|
||||
|
||||
# Clean up disconnected clients
|
||||
for conn in disconnected:
|
||||
self.disconnect(conn)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket endpoint for OPC real-time updates"""
|
||||
await manager.connect(websocket)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Keep connection alive and receive any client messages
|
||||
data = await websocket.receive_text()
|
||||
|
||||
# Echo back for ping/pong
|
||||
if data == "ping":
|
||||
await websocket.send_text("pong")
|
||||
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket error: {e}")
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
async def broadcast_task_update(task_id: int, action: str, task_data: dict):
|
||||
"""Broadcast task update to all connected clients"""
|
||||
# Serialize all datetime objects recursively
|
||||
serialized_data = serialize_datetime(task_data)
|
||||
|
||||
message = {
|
||||
"type": "task_update",
|
||||
"task_id": task_id,
|
||||
"action": action, # created, updated, moved, deleted
|
||||
"data": serialized_data,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
await manager.broadcast(message)
|
||||
|
||||
|
||||
async def broadcast_agent_execution(execution_id: int, status: str, execution_data: dict):
|
||||
"""Broadcast agent execution update"""
|
||||
# Serialize all datetime objects recursively
|
||||
serialized_data = serialize_datetime(execution_data)
|
||||
|
||||
message = {
|
||||
"type": "agent_execution",
|
||||
"execution_id": execution_id,
|
||||
"status": status, # pending, running, completed, failed, pending_approval
|
||||
"data": serialized_data,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
await manager.broadcast(message)
|
||||
|
||||
|
||||
async def broadcast_agent_status(agent_id: str, status: str, message_text: str = ""):
|
||||
"""Broadcast agent status update"""
|
||||
message = {
|
||||
"type": "agent_status",
|
||||
"agent_id": agent_id,
|
||||
"status": status, # idle, working, error
|
||||
"message": message_text,
|
||||
}
|
||||
await manager.broadcast(message)
|
||||
@@ -1,28 +1,34 @@
|
||||
"""WebAuthn Passkey authentication endpoints."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from webauthn import (
|
||||
generate_registration_options,
|
||||
verify_registration_response,
|
||||
generate_authentication_options,
|
||||
verify_authentication_response
|
||||
generate_registration_options,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url, options_to_json
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement
|
||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes, options_to_json
|
||||
import auth
|
||||
|
||||
import auth_service as auth
|
||||
import config
|
||||
|
||||
router = APIRouter()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In-memory challenge store with TTL
|
||||
_challenges = {}
|
||||
|
||||
|
||||
def _store_challenge(challenge: bytes):
|
||||
"""Store a WebAuthn challenge with timestamp for TTL tracking."""
|
||||
_challenges[challenge] = time.time()
|
||||
@@ -32,12 +38,13 @@ def _store_challenge(challenge: bytes):
|
||||
for k in expired:
|
||||
_challenges.pop(k, None)
|
||||
|
||||
|
||||
def _get_challenge(client_data_b64: str) -> bytes:
|
||||
"""Extract and validate challenge from clientDataJSON."""
|
||||
if not client_data_b64:
|
||||
raise HTTPException(status_code=400, detail="Missing clientDataJSON")
|
||||
try:
|
||||
data = json.loads(base64url_to_bytes(client_data_b64).decode('utf-8'))
|
||||
data = json.loads(base64url_to_bytes(client_data_b64).decode("utf-8"))
|
||||
chal_bytes = base64url_to_bytes(data.get("challenge", ""))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid clientDataJSON")
|
||||
@@ -47,14 +54,12 @@ def _get_challenge(client_data_b64: str) -> bytes:
|
||||
raise HTTPException(status_code=400, detail="Challenge expired or invalid")
|
||||
return chal_bytes
|
||||
|
||||
|
||||
@router.post("/passkey/register/options")
|
||||
async def passkey_register_options(current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_register_options(current_user=Depends(auth.get_current_user)):
|
||||
"""Generate WebAuthn registration options for adding a new passkey."""
|
||||
existing = auth.load_passkey_credentials()
|
||||
exclude = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
|
||||
for c in existing
|
||||
]
|
||||
exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing]
|
||||
options = generate_registration_options(
|
||||
rp_id=config.WEBAUTHN_RP_ID,
|
||||
rp_name="NAS Dashboard",
|
||||
@@ -66,8 +71,9 @@ async def passkey_register_options(current_user = Depends(auth.get_current_user)
|
||||
_store_challenge(options.challenge)
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/passkey/register/verify")
|
||||
async def passkey_register_verify(request: Request, current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_register_verify(request: Request, current_user=Depends(auth.get_current_user)):
|
||||
"""Verify and save a new passkey registration."""
|
||||
body = await request.json()
|
||||
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
|
||||
@@ -79,17 +85,23 @@ async def passkey_register_verify(request: Request, current_user = Depends(auth.
|
||||
expected_origin=config.WEBAUTHN_ORIGINS,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
logger.exception("Passkey registration verification failed")
|
||||
raise HTTPException(status_code=400, detail="Invalid passkey registration")
|
||||
|
||||
auth.save_passkey_credential({
|
||||
auth.save_passkey_credential(
|
||||
{
|
||||
"credential_id": bytes_to_base64url(verification.credential_id),
|
||||
"public_key": bytes_to_base64url(verification.credential_public_key),
|
||||
"sign_count": verification.sign_count,
|
||||
"name": body.get("name", "Passkey"),
|
||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
})
|
||||
"username": current_user.username,
|
||||
"role": current_user.role,
|
||||
}
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/passkey/login/options")
|
||||
@limiter.limit("10/minute")
|
||||
async def passkey_login_options(request: Request):
|
||||
@@ -98,10 +110,7 @@ async def passkey_login_options(request: Request):
|
||||
if not creds:
|
||||
raise HTTPException(status_code=404, detail="No passkeys registered")
|
||||
|
||||
allow = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
|
||||
for c in creds
|
||||
]
|
||||
allow = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in creds]
|
||||
options = generate_authentication_options(
|
||||
rp_id=config.WEBAUTHN_RP_ID,
|
||||
allow_credentials=allow,
|
||||
@@ -110,6 +119,7 @@ async def passkey_login_options(request: Request):
|
||||
_store_challenge(options.challenge)
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
|
||||
@router.post("/passkey/login/verify")
|
||||
@limiter.limit("10/minute")
|
||||
async def passkey_login_verify(request: Request, response: Response):
|
||||
@@ -132,7 +142,8 @@ async def passkey_login_verify(request: Request, response: Response):
|
||||
credential_current_sign_count=matched["sign_count"],
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
logger.exception("Passkey authentication verification failed")
|
||||
raise HTTPException(status_code=400, detail="Invalid passkey authentication")
|
||||
|
||||
# Update sign count
|
||||
matched["sign_count"] = verification.new_sign_count
|
||||
@@ -140,38 +151,39 @@ async def passkey_login_verify(request: Request, response: Response):
|
||||
data["passkey_credentials"] = creds
|
||||
auth._save_auth_data(data)
|
||||
|
||||
username = config.ADMIN_USER
|
||||
# Use stored username and role from passkey credential
|
||||
username = matched.get("username", config.ADMIN_USER)
|
||||
role = matched.get("role", "admin")
|
||||
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username, "role": "admin"},
|
||||
data={"sub": username, "role": role},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
|
||||
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
|
||||
auth.set_auth_cookies(response, access_token, refresh_token)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": username,
|
||||
"role": "admin"
|
||||
"role": role,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/passkey/list")
|
||||
async def passkey_list(current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_list(current_user=Depends(auth.get_current_user)):
|
||||
"""List all registered passkeys for the current user."""
|
||||
creds = auth.load_passkey_credentials()
|
||||
return {
|
||||
"passkeys": [
|
||||
{
|
||||
"id": c["credential_id"],
|
||||
"name": c.get("name", "Passkey"),
|
||||
"created_at": c.get("created_at", "")
|
||||
}
|
||||
{"id": c["credential_id"], "name": c.get("name", "Passkey"), "created_at": c.get("created_at", "")}
|
||||
for c in creds
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/passkey/delete")
|
||||
async def passkey_delete(request: Request, current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_delete(request: Request, current_user=Depends(auth.get_current_user)):
|
||||
"""Delete a specific passkey by credential ID."""
|
||||
body = await request.json()
|
||||
cred_id = body.get("id")
|
||||
@@ -181,8 +193,9 @@ async def passkey_delete(request: Request, current_user = Depends(auth.get_curre
|
||||
auth._save_auth_data(data)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/passkey/clear")
|
||||
async def passkey_clear(current_user = Depends(auth.get_current_user)):
|
||||
async def passkey_clear(current_user=Depends(auth.get_current_user)):
|
||||
"""Clear all passkeys for the current user."""
|
||||
auth.clear_passkey_credentials()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import docker
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta, time
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
|
||||
import docker
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from config import DOCKER_HOST
|
||||
|
||||
router = APIRouter()
|
||||
client = docker.DockerClient(base_url=DOCKER_HOST, timeout=10)
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_docker_client():
|
||||
"""Get or create Docker client (lazy initialization)."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = docker.DockerClient(base_url=DOCKER_HOST, timeout=10)
|
||||
return _client
|
||||
|
||||
|
||||
_tz_cst = timezone(timedelta(hours=8))
|
||||
|
||||
@@ -59,9 +71,8 @@ def _parse_authelia_logs(lines: str, limit: int) -> list[dict]:
|
||||
level = obj.get("level", "")
|
||||
msg = obj.get("msg", "")
|
||||
|
||||
is_failed = (
|
||||
level in ("error", "warning")
|
||||
and any(kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied"))
|
||||
is_failed = level in ("error", "warning") and any(
|
||||
kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied")
|
||||
)
|
||||
# Also catch "not authorized" for anonymous access attempts
|
||||
is_unauthorized = "is not authorized" in msg.lower()
|
||||
@@ -75,14 +86,16 @@ def _parse_authelia_logs(lines: str, limit: int) -> list[dict]:
|
||||
except Exception:
|
||||
ts = ts_raw[:19] if ts_raw else ""
|
||||
|
||||
entries.append({
|
||||
entries.append(
|
||||
{
|
||||
"type": "auth_failure" if is_failed else "unauthorized",
|
||||
"timestamp": ts,
|
||||
"message": msg,
|
||||
"username": obj.get("username", obj.get("user", "")),
|
||||
"ip": obj.get("remote_ip", obj.get("ip", "")),
|
||||
"level": level,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return entries[-limit:]
|
||||
|
||||
@@ -114,18 +127,24 @@ def _parse_caddy_logs(lines: str, limit: int) -> list[dict]:
|
||||
if isinstance(ts_raw, (int, float)):
|
||||
ts = datetime.fromtimestamp(ts_raw, tz=_tz_cst).strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")).astimezone(_tz_cst).strftime("%Y-%m-%d %H:%M:%S")
|
||||
ts = (
|
||||
datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
||||
.astimezone(_tz_cst)
|
||||
.strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
except Exception:
|
||||
ts = str(ts_raw)[:19]
|
||||
|
||||
entries.append({
|
||||
entries.append(
|
||||
{
|
||||
"type": "suspicious_request",
|
||||
"timestamp": ts,
|
||||
"message": f"{status} {obj.get('request', {}).get('method', 'GET')} {uri}",
|
||||
"ip": remote_ip,
|
||||
"status": status,
|
||||
"path": uri,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return entries[-limit:]
|
||||
|
||||
@@ -190,6 +209,7 @@ def security_logs(
|
||||
|
||||
# Authelia logs
|
||||
try:
|
||||
client = get_docker_client()
|
||||
authelia = client.containers.get("authelia")
|
||||
raw = authelia.logs(tail=tail, timestamps=False).decode(errors="replace")
|
||||
results.extend(_parse_authelia_logs(raw, limit))
|
||||
@@ -214,6 +234,7 @@ def security_stats(tail: int = Query(500, le=2000)):
|
||||
caddy_entries = []
|
||||
|
||||
try:
|
||||
client = get_docker_client()
|
||||
authelia = client.containers.get("authelia")
|
||||
raw = authelia.logs(tail=tail, timestamps=False).decode(errors="replace")
|
||||
auth_entries = _parse_authelia_logs(raw, 1000)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import os
|
||||
import shutil
|
||||
import psutil
|
||||
import platform
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Query, Request, HTTPException
|
||||
import psutil
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
import config
|
||||
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN
|
||||
from config import OPENCLAW_GATEWAY_TOKEN, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -36,7 +38,15 @@ def system_stats():
|
||||
seen_devices.add(mount.device)
|
||||
try:
|
||||
u = shutil.disk_usage(mount.mountpoint)
|
||||
volumes.append({"mount": mount.mountpoint, "total": u.total, "used": u.used, "free": u.free, "percent": round(u.used / u.total * 100, 1)})
|
||||
volumes.append(
|
||||
{
|
||||
"mount": mount.mountpoint,
|
||||
"total": u.total,
|
||||
"used": u.used,
|
||||
"free": u.free,
|
||||
"percent": round(u.used / u.total * 100, 1),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
mem = psutil.virtual_memory()
|
||||
@@ -90,7 +100,18 @@ def audit_log(lines: int = Query(100, le=500)):
|
||||
level = _classify(method, path, status, user)
|
||||
if level == "noise":
|
||||
continue
|
||||
entries.append({"ts": ts, "ip": ip, "user": user, "method": method, "path": path, "status": status, "duration": dur, "level": level})
|
||||
entries.append(
|
||||
{
|
||||
"ts": ts,
|
||||
"ip": ip,
|
||||
"user": user,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"status": status,
|
||||
"duration": dur,
|
||||
"level": level,
|
||||
}
|
||||
)
|
||||
return {"entries": entries}
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import asyncio
|
||||
import struct
|
||||
import logging
|
||||
import shlex
|
||||
import struct
|
||||
from collections import Counter
|
||||
from fastapi import WebSocket, Query
|
||||
|
||||
import asyncssh
|
||||
import jwt
|
||||
from fastapi import Query, WebSocket
|
||||
|
||||
import auth_service as auth
|
||||
import config
|
||||
import auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,12 +57,14 @@ def build_host_command(host_config: dict) -> str | None:
|
||||
quoted_container = shlex.quote(container)
|
||||
quoted_session = shlex.quote(session_name)
|
||||
quoted_marker = shlex.quote(PERSISTENCE_STATUS_PREFIX)
|
||||
# Use login shell to ensure env vars are loaded from .bashrc
|
||||
attach_cmd = (
|
||||
f"{docker_bin} exec -it {quoted_container} "
|
||||
f"tmux new-session -A -s {quoted_session}"
|
||||
f"bash -l -c 'cd /repos/nas-tools && tmux new-session -A -s {quoted_session}'"
|
||||
)
|
||||
|
||||
script = " ".join([
|
||||
script = " ".join(
|
||||
[
|
||||
"if",
|
||||
f"{docker_bin} exec {quoted_container} tmux has-session -t {quoted_session} >/dev/null 2>&1;",
|
||||
"then",
|
||||
@@ -68,7 +73,8 @@ def build_host_command(host_config: dict) -> str | None:
|
||||
f"printf '%screated\\n' {quoted_marker};",
|
||||
"fi;",
|
||||
f"exec {attach_cmd}",
|
||||
])
|
||||
]
|
||||
)
|
||||
return f"bash -lc {shlex.quote(script)}"
|
||||
|
||||
|
||||
@@ -90,19 +96,29 @@ async def _release_session(session_id: int | None):
|
||||
_active_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str | None = Query(None)):
|
||||
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||
if host not in HOSTS:
|
||||
await websocket.close(code=1008, reason="Unknown host")
|
||||
return
|
||||
|
||||
access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME) or token
|
||||
access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME)
|
||||
if not access_token:
|
||||
logger.warning("WebSocket auth failed for %s: no access token cookie", host)
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
|
||||
try:
|
||||
user = await auth.get_current_user_ws(access_token)
|
||||
except Exception:
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
logger.warning("WebSocket auth failed for %s: token expired - %s", host, e)
|
||||
await websocket.close(code=1008, reason="Token expired")
|
||||
return
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning("WebSocket auth failed for %s: invalid token - %s", host, e)
|
||||
await websocket.close(code=1008, reason="Invalid token")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("WebSocket auth failed for %s: unexpected error - %s", host, e)
|
||||
await websocket.close(code=1008, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -122,19 +138,23 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
||||
session_id = session_or_code
|
||||
|
||||
await websocket.accept()
|
||||
logger.info("WebSocket accepted for %s (user: %s)", host, user.username)
|
||||
|
||||
h = HOSTS[host]
|
||||
try:
|
||||
conn = await asyncio.wait_for(
|
||||
asyncssh.connect(
|
||||
h["host"], username=h["user"],
|
||||
client_keys=[h["key"]], known_hosts=_known_hosts,
|
||||
h["host"],
|
||||
username=h["user"],
|
||||
client_keys=[h["key"]],
|
||||
known_hosts=_known_hosts,
|
||||
keepalive_interval=15,
|
||||
keepalive_count_max=8,
|
||||
),
|
||||
timeout=10.0
|
||||
timeout=10.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info("SSH connection established to %s", host)
|
||||
except TimeoutError:
|
||||
logger.error("SSH connection to %s timed out after 10s", host)
|
||||
await _release_session(session_id)
|
||||
await websocket.close(code=1011, reason="SSH connection timeout")
|
||||
@@ -147,7 +167,9 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
||||
|
||||
try:
|
||||
process = await conn.create_process(
|
||||
build_host_command(h), term_type="xterm-256color", term_size=(80, 24),
|
||||
build_host_command(h),
|
||||
term_type="xterm-256color",
|
||||
term_size=(80, 24),
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
@@ -177,16 +199,31 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
||||
rows = struct.unpack("!H", data[3:5])[0]
|
||||
process.channel.change_terminal_size(cols, rows)
|
||||
else:
|
||||
try:
|
||||
process.stdin.write(data)
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
logger.warning("Failed to write to stdin for %s: %s", host, e)
|
||||
break
|
||||
elif "text" in msg and msg["text"]:
|
||||
if msg["text"] == "__ping__":
|
||||
logger.debug("Received ping from %s, sending pong", host)
|
||||
await websocket.send_text("__pong__")
|
||||
continue
|
||||
try:
|
||||
process.stdin.write(msg["text"].encode())
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
logger.warning("Failed to write to stdin for %s: %s", host, e)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.info("WebSocket receive loop ended for %s: %s", host, e)
|
||||
logger.warning("WebSocket receive loop ended for %s: %s", host, e)
|
||||
finally:
|
||||
read_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(read_task, timeout=2.0)
|
||||
except (TimeoutError, asyncio.CancelledError):
|
||||
pass
|
||||
process.close()
|
||||
finally:
|
||||
conn.close()
|
||||
await conn.wait_closed()
|
||||
await _release_session(session_id)
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
"""TOTP (Time-based One-Time Password) 2FA endpoints."""
|
||||
|
||||
import pyotp
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import pyotp
|
||||
import auth
|
||||
|
||||
import auth_service as auth
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class Setup2FAResponse(BaseModel):
|
||||
secret: str
|
||||
uri: str
|
||||
|
||||
|
||||
class Verify2FARequest(BaseModel):
|
||||
secret: str
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/setup-2fa/generate", response_model=Setup2FAResponse)
|
||||
async def generate_2fa(current_user = Depends(auth.get_current_user)):
|
||||
async def generate_2fa(current_user=Depends(auth.get_current_user)):
|
||||
"""Generate a new TOTP secret and provisioning URI for 2FA setup."""
|
||||
secret = pyotp.random_base32()
|
||||
uri = pyotp.totp.TOTP(secret).provisioning_uri(
|
||||
name=current_user.username,
|
||||
issuer_name="NAS Dashboard"
|
||||
)
|
||||
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user.username, issuer_name="NAS Dashboard")
|
||||
return {"secret": secret, "uri": uri}
|
||||
|
||||
|
||||
@router.post("/setup-2fa/verify")
|
||||
async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(auth.get_current_user)):
|
||||
async def verify_2fa_setup(request: Verify2FARequest, current_user=Depends(auth.get_current_user)):
|
||||
"""Verify TOTP code and enable 2FA for the user."""
|
||||
totp = pyotp.TOTP(request.secret)
|
||||
if not totp.verify(request.code):
|
||||
@@ -34,8 +37,9 @@ async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(aut
|
||||
auth.save_totp_secret(request.secret)
|
||||
return {"message": "2FA enabled successfully"}
|
||||
|
||||
|
||||
@router.post("/setup-2fa/disable")
|
||||
async def disable_2fa(current_user = Depends(auth.get_current_user)):
|
||||
async def disable_2fa(current_user=Depends(auth.get_current_user)):
|
||||
"""Disable 2FA for the user."""
|
||||
auth.save_totp_secret("")
|
||||
return {"message": "2FA disabled"}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/api/transmission", tags=["transmission"])
|
||||
|
||||
|
||||
def get_session_id() -> str:
|
||||
"""Get Transmission RPC session ID"""
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
response = client.get(
|
||||
"http://host.docker.internal:9091/transmission/rpc",
|
||||
auth=("admin", "admin")
|
||||
)
|
||||
session_id = response.headers.get("X-Transmission-Session-Id")
|
||||
if not session_id:
|
||||
raise Exception("Failed to get session ID from headers")
|
||||
return session_id
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get Transmission session ID: {str(e)}")
|
||||
|
||||
|
||||
def transmission_rpc(method: str, arguments: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""Make Transmission RPC call"""
|
||||
try:
|
||||
session_id = get_session_id()
|
||||
|
||||
payload = {"method": method}
|
||||
if arguments:
|
||||
payload["arguments"] = arguments
|
||||
|
||||
# Use httpx to make the request
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.post(
|
||||
"http://host.docker.internal:9091/transmission/rpc",
|
||||
json=payload,
|
||||
auth=("admin", "admin"),
|
||||
headers={"X-Transmission-Session-Id": session_id}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("result") == "success":
|
||||
return data.get("arguments", {})
|
||||
else:
|
||||
raise Exception(f"RPC error: {data.get('result')}")
|
||||
else:
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Transmission RPC failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status():
|
||||
"""Get Transmission daemon status"""
|
||||
try:
|
||||
# Check if process is running
|
||||
result = subprocess.run(
|
||||
["ssh", "nas", "ps aux | grep transmission-daemon | grep sc-transmission | grep -v grep"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
is_running = bool(result.stdout.strip())
|
||||
|
||||
if not is_running:
|
||||
return {
|
||||
"running": False,
|
||||
"message": "Transmission daemon is not running"
|
||||
}
|
||||
|
||||
# Get session stats
|
||||
try:
|
||||
stats = transmission_rpc("session-stats")
|
||||
return {
|
||||
"running": True,
|
||||
"stats": stats
|
||||
}
|
||||
except:
|
||||
return {
|
||||
"running": True,
|
||||
"message": "Daemon running but RPC not accessible"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/torrents")
|
||||
async def get_torrents():
|
||||
"""Get list of torrents with tracker status"""
|
||||
try:
|
||||
data = transmission_rpc("torrent-get", {
|
||||
"fields": [
|
||||
"id", "name", "status", "percentDone",
|
||||
"rateDownload", "rateUpload",
|
||||
"uploadRatio", "trackerStats",
|
||||
"error", "errorString"
|
||||
]
|
||||
})
|
||||
|
||||
torrents = data.get("torrents", [])
|
||||
|
||||
# Process tracker stats
|
||||
for torrent in torrents:
|
||||
tracker_errors = []
|
||||
for tracker in torrent.get("trackerStats", []):
|
||||
if tracker.get("lastAnnounceResult") and tracker.get("lastAnnounceResult") != "Success":
|
||||
tracker_errors.append({
|
||||
"host": tracker.get("host"),
|
||||
"error": tracker.get("lastAnnounceResult")
|
||||
})
|
||||
torrent["trackerErrors"] = tracker_errors
|
||||
torrent["hasTrackerErrors"] = len(tracker_errors) > 0
|
||||
|
||||
return {"torrents": torrents}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/trackers")
|
||||
async def get_tracker_summary():
|
||||
"""Get summary of tracker connectivity"""
|
||||
try:
|
||||
data = transmission_rpc("torrent-get", {
|
||||
"fields": ["trackerStats"]
|
||||
})
|
||||
|
||||
torrents = data.get("torrents", [])
|
||||
tracker_summary = {}
|
||||
|
||||
for torrent in torrents:
|
||||
for tracker in torrent.get("trackerStats", []):
|
||||
host = tracker.get("host")
|
||||
if host not in tracker_summary:
|
||||
tracker_summary[host] = {
|
||||
"host": host,
|
||||
"total": 0,
|
||||
"errors": 0,
|
||||
"lastError": None
|
||||
}
|
||||
|
||||
tracker_summary[host]["total"] += 1
|
||||
|
||||
if tracker.get("lastAnnounceResult") and tracker.get("lastAnnounceResult") != "Success":
|
||||
tracker_summary[host]["errors"] += 1
|
||||
tracker_summary[host]["lastError"] = tracker.get("lastAnnounceResult")
|
||||
|
||||
return {"trackers": list(tracker_summary.values())}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats():
|
||||
"""Get Transmission statistics"""
|
||||
try:
|
||||
stats = transmission_rpc("session-stats")
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/start/{torrent_id}")
|
||||
async def start_torrent(torrent_id: int):
|
||||
"""Start a torrent"""
|
||||
try:
|
||||
transmission_rpc("torrent-start", {"ids": [torrent_id]})
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/stop/{torrent_id}")
|
||||
async def stop_torrent(torrent_id: int):
|
||||
"""Stop a torrent"""
|
||||
try:
|
||||
transmission_rpc("torrent-stop", {"ids": [torrent_id]})
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/verify/{torrent_id}")
|
||||
async def verify_torrent(torrent_id: int):
|
||||
"""Verify a torrent"""
|
||||
try:
|
||||
transmission_rpc("torrent-verify", {"ids": [torrent_id]})
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/reannounce/{torrent_id}")
|
||||
async def reannounce_torrent(torrent_id: int):
|
||||
"""Force reannounce to tracker"""
|
||||
try:
|
||||
transmission_rpc("torrent-reannounce", {"ids": [torrent_id]})
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Agent Executor Service - Executes agent tasks and manages their lifecycle
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from db import opc_db
|
||||
from services import email_service
|
||||
from services.agents.base_agent import BaseAgent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Telegram notification settings
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
|
||||
|
||||
|
||||
class AgentExecutor:
|
||||
"""Manages agent execution lifecycle"""
|
||||
|
||||
def __init__(self):
|
||||
self.agents: dict[str, BaseAgent] = {}
|
||||
self.running = False
|
||||
|
||||
async def initialize(self):
|
||||
"""Load agents from database"""
|
||||
agents_data = await opc_db.get_agents(enabled_only=True)
|
||||
|
||||
for agent_data in agents_data:
|
||||
agent = BaseAgent(
|
||||
agent_id=agent_data["id"],
|
||||
name=agent_data["name"],
|
||||
role=agent_data["role"],
|
||||
system_prompt=agent_data["system_prompt"],
|
||||
config=agent_data["config"],
|
||||
)
|
||||
self.agents[agent_data["id"]] = agent
|
||||
logger.info(f"Loaded agent: {agent.name} ({agent.agent_id})")
|
||||
|
||||
async def start(self):
|
||||
"""Start the executor service"""
|
||||
self.running = True
|
||||
print("=== Agent Executor Loop Started ===")
|
||||
logger.info("Agent executor service started")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
print(f"[Executor] Checking for pending executions... (running={self.running})")
|
||||
await self._process_pending_executions()
|
||||
except Exception as e:
|
||||
print(f"[Executor] Error in executor loop: {e}")
|
||||
logger.error(f"Error in executor loop: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
await asyncio.sleep(5) # Check every 5 seconds
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the executor service"""
|
||||
self.running = False
|
||||
logger.info("Agent executor service stopped")
|
||||
|
||||
async def _process_pending_executions(self):
|
||||
"""Process pending agent executions"""
|
||||
# Process both pending and approved executions
|
||||
pending_executions = await opc_db.get_agent_executions(status="pending", limit=10)
|
||||
approved_executions = await opc_db.get_agent_executions(status="approved", limit=10)
|
||||
|
||||
executions = pending_executions + approved_executions
|
||||
print(f"[Executor] Found {len(pending_executions)} pending and {len(approved_executions)} approved executions")
|
||||
|
||||
for execution in executions:
|
||||
try:
|
||||
print(
|
||||
f"[Executor] Processing execution {execution['id']} for agent {execution['agent_id']} (status: {execution['status']})"
|
||||
)
|
||||
|
||||
if execution["status"] == "approved":
|
||||
# Resume approved execution
|
||||
await self._resume_approved_execution(execution)
|
||||
else:
|
||||
# Execute new pending execution
|
||||
await self._execute_agent(execution)
|
||||
except Exception as e:
|
||||
print(f"[Executor] Failed to execute agent {execution['agent_id']}: {e}")
|
||||
logger.error(f"Failed to execute agent {execution['agent_id']}: {e}")
|
||||
await self._mark_execution_failed(execution["id"], str(e))
|
||||
|
||||
async def _execute_agent(self, execution: dict[str, Any]):
|
||||
"""Execute a single agent task"""
|
||||
agent_id = execution["agent_id"]
|
||||
task_id = execution["task_id"]
|
||||
|
||||
# Get agent
|
||||
agent = self.agents.get(agent_id)
|
||||
if not agent:
|
||||
raise ValueError(f"Agent {agent_id} not found")
|
||||
|
||||
# Mark as running
|
||||
await self._update_execution_status(execution["id"], "running")
|
||||
|
||||
# Get task and context
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
# Build context
|
||||
context = await self._build_context(task)
|
||||
|
||||
# Execute agent
|
||||
logger.info(f"Executing agent {agent.name} on task {task['title']}")
|
||||
result = await agent.execute(task, context)
|
||||
|
||||
# Check if requires approval
|
||||
if execution.get("requires_approval") or result.get("requires_approval"):
|
||||
# Mark as pending approval
|
||||
await self._mark_pending_approval(execution["id"], result)
|
||||
await self._send_approval_notification(agent, task, result)
|
||||
else:
|
||||
# Execute actions
|
||||
await self._execute_actions(task, result["actions"])
|
||||
|
||||
# Mark as completed
|
||||
await self._mark_execution_completed(execution["id"], result)
|
||||
|
||||
# Send completion notification
|
||||
await self._send_completion_notification(agent, task, result)
|
||||
|
||||
async def _resume_approved_execution(self, execution: dict[str, Any]):
|
||||
"""Resume an approved execution and execute its actions"""
|
||||
agent_id = execution["agent_id"]
|
||||
task_id = execution["task_id"]
|
||||
|
||||
# Get agent
|
||||
agent = self.agents.get(agent_id)
|
||||
if not agent:
|
||||
raise ValueError(f"Agent {agent_id} not found")
|
||||
|
||||
# Get task
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
# Mark as running
|
||||
await self._update_execution_status(execution["id"], "running")
|
||||
|
||||
# Get proposed actions from execution
|
||||
result = execution.get("output_result", {})
|
||||
actions = execution.get("actions_proposed", [])
|
||||
|
||||
logger.info(f"Resuming approved execution for agent {agent.name} on task {task['title']}")
|
||||
|
||||
# Execute approved actions
|
||||
await self._execute_actions(task, actions)
|
||||
|
||||
# Mark as completed
|
||||
await self._mark_execution_completed(execution["id"], result)
|
||||
|
||||
# Send completion notification
|
||||
await self._send_completion_notification(agent, task, result)
|
||||
|
||||
async def _build_context(self, task: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build context for agent execution"""
|
||||
context = {"task": task, "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
# Get related tasks from same project
|
||||
if task.get("project_id"):
|
||||
related_tasks = await opc_db.get_tasks(project_id=task["project_id"], limit=20)
|
||||
context["related_tasks"] = related_tasks
|
||||
|
||||
# Get task history
|
||||
history = await opc_db.get_task_history(task["id"])
|
||||
context["history"] = history[:10] # Last 10 events
|
||||
|
||||
return context
|
||||
|
||||
async def _execute_actions(self, task: dict[str, Any], actions: list):
|
||||
"""Execute proposed actions"""
|
||||
for action in actions:
|
||||
action_type = action.get("type")
|
||||
|
||||
try:
|
||||
if action_type == "create_subtask":
|
||||
await self._action_create_subtask(task, action)
|
||||
elif action_type == "update_task_status":
|
||||
await self._action_update_status(task, action)
|
||||
elif action_type == "send_notification":
|
||||
await self._action_send_notification(action)
|
||||
elif action_type == "add_comment":
|
||||
await self._action_add_comment(task, action)
|
||||
elif action_type == "error":
|
||||
# Handle error action - log and fail execution
|
||||
error_msg = action.get("message", "Unknown error")
|
||||
logger.error(f"Agent reported error: {error_msg}")
|
||||
raise Exception(error_msg)
|
||||
else:
|
||||
logger.warning(f"Unknown action type: {action_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute action {action_type}: {e}")
|
||||
raise
|
||||
|
||||
async def _action_create_subtask(self, parent_task: dict[str, Any], action: dict[str, Any]):
|
||||
"""Create a subtask"""
|
||||
await opc_db.create_task(
|
||||
title=action.get("title", "Subtask"),
|
||||
description=action.get("description", ""),
|
||||
status="parking_lot",
|
||||
priority=parent_task.get("priority", "medium"),
|
||||
project_id=parent_task.get("project_id"),
|
||||
tags=parent_task.get("tags", []),
|
||||
actor="agent",
|
||||
)
|
||||
logger.info(f"Created subtask: {action.get('title')}")
|
||||
|
||||
async def _action_update_status(self, task: dict[str, Any], action: dict[str, Any]):
|
||||
"""Update task status"""
|
||||
new_status = action.get("status")
|
||||
if new_status:
|
||||
await opc_db.update_task(task_id=task["id"], updates={"status": new_status}, actor="agent")
|
||||
logger.info(f"Updated task {task['id']} status to {new_status}")
|
||||
|
||||
async def _action_send_notification(self, action: dict[str, Any]):
|
||||
"""Send notification"""
|
||||
message = action.get("message", "")
|
||||
if message and TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
|
||||
await self._send_telegram(message)
|
||||
|
||||
async def _action_add_comment(self, task: dict[str, Any], action: dict[str, Any]):
|
||||
"""Add comment to task (stored in history)"""
|
||||
comment = action.get("comment", "")
|
||||
if comment:
|
||||
# Store as task history
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO task_history (task_id, action, actor, actor_type, new_value)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
task["id"],
|
||||
"comment",
|
||||
"agent",
|
||||
"agent",
|
||||
comment,
|
||||
)
|
||||
|
||||
async def _update_execution_status(self, execution_id: int, status: str):
|
||||
"""Update execution status"""
|
||||
await opc_db.update_execution_status(execution_id, status)
|
||||
|
||||
async def _mark_execution_completed(self, execution_id: int, result: dict[str, Any]):
|
||||
"""Mark execution as completed"""
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE agent_executions
|
||||
SET status = $1,
|
||||
output_result = $2,
|
||||
actions_proposed = $3,
|
||||
actions_executed = $3,
|
||||
completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
""",
|
||||
"completed",
|
||||
opc_db.json.dumps(result),
|
||||
opc_db.json.dumps(result.get("actions", [])),
|
||||
execution_id,
|
||||
)
|
||||
|
||||
async def _mark_execution_failed(self, execution_id: int, error: str):
|
||||
"""Mark execution as failed"""
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE agent_executions
|
||||
SET status = $1, error_message = $2, completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
""",
|
||||
"failed",
|
||||
error,
|
||||
execution_id,
|
||||
)
|
||||
|
||||
async def _mark_pending_approval(self, execution_id: int, result: dict[str, Any]):
|
||||
"""Mark execution as pending approval"""
|
||||
pool = await opc_db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE agent_executions
|
||||
SET status = $1,
|
||||
output_result = $2,
|
||||
actions_proposed = $3,
|
||||
requires_approval = TRUE
|
||||
WHERE id = $4
|
||||
""",
|
||||
"pending_approval",
|
||||
opc_db.json.dumps(result),
|
||||
opc_db.json.dumps(result.get("actions", [])),
|
||||
execution_id,
|
||||
)
|
||||
|
||||
async def _send_telegram(self, message: str):
|
||||
"""Send Telegram notification"""
|
||||
try:
|
||||
proxy_url = os.environ.get("TELEGRAM_PROXY")
|
||||
async with httpx.AsyncClient(proxy=proxy_url, timeout=10.0) as client:
|
||||
await client.post(
|
||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
data={"chat_id": TELEGRAM_CHAT_ID, "text": message, "parse_mode": "Markdown"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram notification: {e}")
|
||||
|
||||
async def _send_approval_notification(self, agent: BaseAgent, task: dict[str, Any], result: dict[str, Any]):
|
||||
"""Send notification for approval request"""
|
||||
message = f"""🤖 *Agent Approval Required*
|
||||
|
||||
Agent: {agent.name}
|
||||
Task: {task['title']}
|
||||
|
||||
Reasoning: {result.get('reasoning', 'No reasoning provided')}
|
||||
|
||||
Proposed Actions:
|
||||
"""
|
||||
for i, action in enumerate(result.get("actions", []), 1):
|
||||
message += f"{i}. {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}\n"
|
||||
|
||||
message += "\nPlease review and approve in the OPC dashboard."
|
||||
|
||||
# Send Telegram
|
||||
await self._send_telegram(message)
|
||||
|
||||
# Send Email
|
||||
await email_service.send_agent_approval_email(
|
||||
agent_name=agent.name,
|
||||
task=task,
|
||||
reasoning=result.get("reasoning", "No reasoning provided"),
|
||||
actions=result.get("actions", []),
|
||||
)
|
||||
|
||||
async def _send_completion_notification(self, agent: BaseAgent, task: dict[str, Any], result: dict[str, Any]):
|
||||
"""Send notification for completed execution"""
|
||||
message = f"""✅ *Agent Task Completed*
|
||||
|
||||
Agent: {agent.name}
|
||||
Task: {task['title']}
|
||||
|
||||
Actions Executed: {len(result.get('actions', []))}
|
||||
"""
|
||||
# Send Telegram
|
||||
await self._send_telegram(message)
|
||||
|
||||
# Send Email
|
||||
await email_service.send_agent_completion_email(
|
||||
agent_name=agent.name, task=task, actions_count=len(result.get("actions", []))
|
||||
)
|
||||
|
||||
|
||||
# Global executor instance
|
||||
_executor: AgentExecutor | None = None
|
||||
|
||||
|
||||
async def get_executor() -> AgentExecutor:
|
||||
"""Get or create executor instance"""
|
||||
global _executor
|
||||
if _executor is None:
|
||||
_executor = AgentExecutor()
|
||||
await _executor.initialize()
|
||||
return _executor
|
||||
|
||||
|
||||
async def start_executor():
|
||||
"""Start the executor service - initializes and starts the background task"""
|
||||
print("start_executor() called")
|
||||
try:
|
||||
executor = await get_executor()
|
||||
print(f"Executor obtained: {executor is not None}, running: {executor.running}")
|
||||
# Don't await start() - it's an infinite loop!
|
||||
# Just call it to set running=True and start the loop
|
||||
# The task will be managed by the caller
|
||||
asyncio.create_task(executor.start())
|
||||
print("Executor background task created")
|
||||
except Exception as e:
|
||||
print(f"ERROR in start_executor: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Base Agent Class - Foundation for all OPC agents
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class BaseAgent:
|
||||
"""Base class for all OPC agents"""
|
||||
|
||||
def __init__(self, agent_id: str, name: str, role: str, system_prompt: str, config: dict[str, Any]):
|
||||
self.agent_id = agent_id
|
||||
self.name = name
|
||||
self.role = role
|
||||
self.system_prompt = system_prompt
|
||||
self.config = config
|
||||
self.litellm_url = os.getenv("LITELLM_URL", "http://litellm:4005")
|
||||
self.litellm_api_key = os.getenv("LITELLM_API_KEY", "")
|
||||
|
||||
async def execute(self, task: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Execute agent on a task
|
||||
Returns: {
|
||||
"actions_proposed": [...],
|
||||
"reasoning": "...",
|
||||
"requires_approval": bool
|
||||
}
|
||||
"""
|
||||
# Build prompt with task context
|
||||
user_prompt = self._build_prompt(task, context)
|
||||
|
||||
# Call LLM
|
||||
response = await self._call_llm(user_prompt)
|
||||
|
||||
# Parse response and extract actions
|
||||
result = self._parse_response(response)
|
||||
|
||||
return result
|
||||
|
||||
def _build_prompt(self, task: dict[str, Any], context: dict[str, Any]) -> str:
|
||||
"""Build the prompt for the LLM"""
|
||||
prompt = f"""You are {self.name}, a {self.role} agent.
|
||||
|
||||
Task Details:
|
||||
- Title: {task.get('title')}
|
||||
- Description: {task.get('description', 'No description')}
|
||||
- Status: {task.get('status')}
|
||||
- Priority: {task.get('priority')}
|
||||
- Tags: {', '.join(task.get('tags', []))}
|
||||
|
||||
Context:
|
||||
- Project: {context.get('project', 'None')}
|
||||
- Related tasks: {len(context.get('related_tasks', []))}
|
||||
- Company goals: {context.get('goals', 'None')}
|
||||
|
||||
Your role: {self.system_prompt}
|
||||
|
||||
Based on this task, propose concrete actions you would take. Format your response as JSON:
|
||||
{{
|
||||
"reasoning": "Your analysis of the task",
|
||||
"actions": [
|
||||
{{"type": "create_subtask", "title": "...", "description": "..."}},
|
||||
{{"type": "update_task_status", "status": "..."}},
|
||||
{{"type": "send_notification", "message": "..."}},
|
||||
{{"type": "request_approval", "question": "..."}}
|
||||
],
|
||||
"requires_approval": false
|
||||
}}
|
||||
|
||||
Only propose actions you can actually execute. Be specific and actionable."""
|
||||
|
||||
return prompt
|
||||
|
||||
async def _call_llm(self, prompt: str) -> str:
|
||||
"""Call LiteLLM proxy"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
model = self.config.get("model", "claude-sonnet-4-6")
|
||||
temperature = self.config.get("temperature", 0.7)
|
||||
|
||||
try:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
# Only add auth header if API key is set and not empty
|
||||
if self.litellm_api_key and self.litellm_api_key.strip():
|
||||
headers["Authorization"] = f"Bearer {self.litellm_api_key}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.litellm_url}/chat/completions",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": temperature,
|
||||
"max_tokens": 2000,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"LiteLLM connection failed: {e}")
|
||||
raise Exception(f"LiteLLM service unavailable: {str(e)}")
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"LiteLLM request timeout: {e}")
|
||||
raise Exception(f"LiteLLM request timeout: {str(e)}")
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"LiteLLM HTTP error: {e.response.status_code} - {e.response.text}")
|
||||
raise Exception(f"LiteLLM HTTP error {e.response.status_code}: {e.response.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"LiteLLM call failed: {e}")
|
||||
raise Exception(f"LLM call failed: {str(e)}")
|
||||
|
||||
def _parse_response(self, response: str) -> dict[str, Any]:
|
||||
"""Parse LLM response and extract actions"""
|
||||
try:
|
||||
# Try to extract JSON from response
|
||||
if "```json" in response:
|
||||
json_str = response.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in response:
|
||||
json_str = response.split("```")[1].split("```")[0].strip()
|
||||
else:
|
||||
json_str = response.strip()
|
||||
|
||||
result = json.loads(json_str)
|
||||
|
||||
# Validate structure
|
||||
if "actions" not in result:
|
||||
result["actions"] = []
|
||||
if "reasoning" not in result:
|
||||
result["reasoning"] = "No reasoning provided"
|
||||
if "requires_approval" not in result:
|
||||
# Check if any action requires approval
|
||||
result["requires_approval"] = any(
|
||||
action.get("type") == "request_approval" for action in result["actions"]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: return error action
|
||||
return {
|
||||
"reasoning": f"Failed to parse response: {str(e)}",
|
||||
"actions": [{"type": "error", "message": f"Agent response parsing failed: {str(e)}"}],
|
||||
"requires_approval": False,
|
||||
}
|
||||
|
||||
def get_capabilities(self) -> list[str]:
|
||||
"""Get agent capabilities"""
|
||||
return self.config.get("capabilities", [])
|
||||
|
||||
def requires_approval(self) -> bool:
|
||||
"""Check if agent requires approval for actions"""
|
||||
return self.config.get("approval_level") == "cxo"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Email notification service for OPC
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Email configuration from environment
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER)
|
||||
SMTP_TO = os.getenv("SMTP_TO", "")
|
||||
|
||||
|
||||
async def send_email(subject: str, body: str, html_body: str = None):
|
||||
"""Send email notification"""
|
||||
if not SMTP_USER or not SMTP_PASSWORD or not SMTP_TO:
|
||||
logger.warning("Email not configured, skipping notification")
|
||||
return
|
||||
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = SMTP_FROM
|
||||
msg["To"] = SMTP_TO
|
||||
|
||||
# Add plain text part
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
|
||||
# Add HTML part if provided
|
||||
if html_body:
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
# Send email
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
||||
server.starttls()
|
||||
server.login(SMTP_USER, SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info(f"Email sent: {subject}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
|
||||
|
||||
async def send_task_assigned_email(task: dict, assigned_to: str, assigned_type: str):
|
||||
"""Send email when task is assigned"""
|
||||
subject = f"Task Assigned: {task['title']}"
|
||||
|
||||
body = f"""You have been assigned a new task:
|
||||
|
||||
Title: {task['title']}
|
||||
Priority: {task['priority']}
|
||||
Status: {task['status']}
|
||||
|
||||
Description:
|
||||
{task.get('description', 'No description')}
|
||||
|
||||
View task: https://nas.jimmygan.com/?page=opc
|
||||
"""
|
||||
|
||||
html_body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Task Assigned</h2>
|
||||
<p>You have been assigned a new task:</p>
|
||||
<table style="border-collapse: collapse; margin: 20px 0;">
|
||||
<tr><td style="padding: 8px; font-weight: bold;">Title:</td><td style="padding: 8px;">{task['title']}</td></tr>
|
||||
<tr><td style="padding: 8px; font-weight: bold;">Priority:</td><td style="padding: 8px;">{task['priority']}</td></tr>
|
||||
<tr><td style="padding: 8px; font-weight: bold;">Status:</td><td style="padding: 8px;">{task['status']}</td></tr>
|
||||
</table>
|
||||
<p><strong>Description:</strong></p>
|
||||
<p>{task.get('description', 'No description')}</p>
|
||||
<p><a href="https://nas.jimmygan.com/?page=opc" style="background-color: #4F46E5; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">View Task</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
await send_email(subject, body, html_body)
|
||||
|
||||
|
||||
async def send_agent_approval_email(agent_name: str, task: dict, reasoning: str, actions: list):
|
||||
"""Send email for agent approval request"""
|
||||
subject = f"Agent Approval Required: {agent_name}"
|
||||
|
||||
actions_text = "\n".join(
|
||||
[f"- {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}" for action in actions]
|
||||
)
|
||||
|
||||
body = f"""Agent approval required:
|
||||
|
||||
Agent: {agent_name}
|
||||
Task: {task['title']}
|
||||
|
||||
Reasoning:
|
||||
{reasoning}
|
||||
|
||||
Proposed Actions:
|
||||
{actions_text}
|
||||
|
||||
Please review and approve in the OPC dashboard:
|
||||
https://nas.jimmygan.com/?page=opc
|
||||
"""
|
||||
|
||||
html_body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>🤖 Agent Approval Required</h2>
|
||||
<p><strong>Agent:</strong> {agent_name}</p>
|
||||
<p><strong>Task:</strong> {task['title']}</p>
|
||||
<h3>Reasoning:</h3>
|
||||
<p>{reasoning}</p>
|
||||
<h3>Proposed Actions:</h3>
|
||||
<ul>
|
||||
{"".join([f"<li><strong>{action.get('type')}</strong>: {action.get('title', action.get('message', 'N/A'))}</li>" for action in actions])}
|
||||
</ul>
|
||||
<p><a href="https://nas.jimmygan.com/?page=opc" style="background-color: #4F46E5; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">Review & Approve</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
await send_email(subject, body, html_body)
|
||||
|
||||
|
||||
async def send_agent_completion_email(agent_name: str, task: dict, actions_count: int):
|
||||
"""Send email when agent completes task"""
|
||||
subject = f"Agent Task Completed: {agent_name}"
|
||||
|
||||
body = f"""Agent has completed a task:
|
||||
|
||||
Agent: {agent_name}
|
||||
Task: {task['title']}
|
||||
Actions Executed: {actions_count}
|
||||
|
||||
View details: https://nas.jimmygan.com/?page=opc
|
||||
"""
|
||||
|
||||
html_body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>✅ Agent Task Completed</h2>
|
||||
<p><strong>Agent:</strong> {agent_name}</p>
|
||||
<p><strong>Task:</strong> {task['title']}</p>
|
||||
<p><strong>Actions Executed:</strong> {actions_count}</p>
|
||||
<p><a href="https://nas.jimmygan.com/?page=opc" style="background-color: #10B981; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">View Details</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
await send_email(subject, body, html_body)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
PDF Invoice Generation Service
|
||||
"""
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
|
||||
def generate_invoice_pdf(
|
||||
invoice_number: str,
|
||||
client: dict[str, Any],
|
||||
time_entries: list[dict[str, Any]],
|
||||
hourly_rate: float = 100.0,
|
||||
company_name: str = "Your Company",
|
||||
company_address: str = "",
|
||||
company_email: str = "",
|
||||
company_phone: str = "",
|
||||
) -> bytes:
|
||||
"""
|
||||
Generate PDF invoice from time entries
|
||||
|
||||
Returns: PDF bytes
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buffer, pagesize=letter)
|
||||
elements = []
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# Custom styles
|
||||
title_style = ParagraphStyle(
|
||||
"CustomTitle",
|
||||
parent=styles["Heading1"],
|
||||
fontSize=24,
|
||||
textColor=colors.HexColor("#1F2937"),
|
||||
spaceAfter=30,
|
||||
)
|
||||
|
||||
header_style = ParagraphStyle(
|
||||
"Header",
|
||||
parent=styles["Normal"],
|
||||
fontSize=10,
|
||||
textColor=colors.HexColor("#6B7280"),
|
||||
)
|
||||
|
||||
# Company Header
|
||||
elements.append(Paragraph(company_name, title_style))
|
||||
if company_address:
|
||||
elements.append(Paragraph(company_address, header_style))
|
||||
if company_email:
|
||||
elements.append(Paragraph(f"Email: {company_email}", header_style))
|
||||
if company_phone:
|
||||
elements.append(Paragraph(f"Phone: {company_phone}", header_style))
|
||||
|
||||
elements.append(Spacer(1, 0.3 * inch))
|
||||
|
||||
# Invoice Title
|
||||
invoice_title = ParagraphStyle(
|
||||
"InvoiceTitle",
|
||||
parent=styles["Heading2"],
|
||||
fontSize=18,
|
||||
textColor=colors.HexColor("#4F46E5"),
|
||||
)
|
||||
elements.append(Paragraph("INVOICE", invoice_title))
|
||||
elements.append(Spacer(1, 0.2 * inch))
|
||||
|
||||
# Invoice Details
|
||||
invoice_date = datetime.now().strftime("%B %d, %Y")
|
||||
invoice_data = [
|
||||
["Invoice Number:", invoice_number],
|
||||
["Invoice Date:", invoice_date],
|
||||
["Client:", client.get("name", "N/A")],
|
||||
]
|
||||
|
||||
if client.get("company"):
|
||||
invoice_data.append(["Company:", client["company"]])
|
||||
if client.get("email"):
|
||||
invoice_data.append(["Email:", client["email"]])
|
||||
|
||||
invoice_table = Table(invoice_data, colWidths=[2 * inch, 4 * inch])
|
||||
invoice_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
||||
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 10),
|
||||
("TEXTCOLOR", (0, 0), (0, -1), colors.HexColor("#374151")),
|
||||
("TEXTCOLOR", (1, 0), (1, -1), colors.HexColor("#1F2937")),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(invoice_table)
|
||||
elements.append(Spacer(1, 0.4 * inch))
|
||||
|
||||
# Time Entries Table
|
||||
table_data = [["Date", "Description", "Hours", "Rate", "Amount"]]
|
||||
|
||||
total_hours = 0
|
||||
total_amount = 0
|
||||
|
||||
for entry in time_entries:
|
||||
hours = entry["duration_minutes"] / 60
|
||||
rate = entry.get("hourly_rate", hourly_rate)
|
||||
amount = hours * rate
|
||||
|
||||
total_hours += hours
|
||||
total_amount += amount
|
||||
|
||||
date_str = entry["started_at"].strftime("%Y-%m-%d") if entry.get("started_at") else "N/A"
|
||||
desc = entry.get("description", "Work performed")
|
||||
|
||||
table_data.append(
|
||||
[date_str, desc[:50] + "..." if len(desc) > 50 else desc, f"{hours:.2f}", f"${rate:.2f}", f"${amount:.2f}"]
|
||||
)
|
||||
|
||||
# Add totals row
|
||||
table_data.append(["", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}"])
|
||||
|
||||
# Create table
|
||||
time_table = Table(table_data, colWidths=[1.2 * inch, 2.8 * inch, 0.8 * inch, 0.8 * inch, 1 * inch])
|
||||
time_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
# Header row
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4F46E5")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 11),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
|
||||
# Data rows
|
||||
("FONTNAME", (0, 1), (-1, -2), "Helvetica"),
|
||||
("FONTSIZE", (0, 1), (-1, -2), 9),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, colors.HexColor("#F9FAFB")]),
|
||||
("GRID", (0, 0), (-1, -2), 0.5, colors.HexColor("#E5E7EB")),
|
||||
# Total row
|
||||
("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#F3F4F6")),
|
||||
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, -1), (-1, -1), 11),
|
||||
("TOPPADDING", (0, -1), (-1, -1), 12),
|
||||
("BOTTOMPADDING", (0, -1), (-1, -1), 12),
|
||||
# Alignment
|
||||
("ALIGN", (2, 0), (-1, -1), "RIGHT"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
elements.append(time_table)
|
||||
elements.append(Spacer(1, 0.5 * inch))
|
||||
|
||||
# Payment Terms
|
||||
terms_style = ParagraphStyle(
|
||||
"Terms",
|
||||
parent=styles["Normal"],
|
||||
fontSize=9,
|
||||
textColor=colors.HexColor("#6B7280"),
|
||||
)
|
||||
elements.append(Paragraph("<b>Payment Terms:</b> Due within 30 days", terms_style))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
elements.append(Paragraph("Thank you for your business!", terms_style))
|
||||
|
||||
# Build PDF
|
||||
doc.build(elements)
|
||||
|
||||
pdf_bytes = buffer.getvalue()
|
||||
buffer.close()
|
||||
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
def generate_invoice_from_project(
|
||||
project_id: int, client: dict[str, Any], time_entries: list[dict[str, Any]], invoice_number: str = None
|
||||
) -> bytes:
|
||||
"""Generate invoice for a project"""
|
||||
if not invoice_number:
|
||||
invoice_number = f"INV-{datetime.now().strftime('%Y%m%d')}-{project_id}"
|
||||
|
||||
return generate_invoice_pdf(
|
||||
invoice_number=invoice_number,
|
||||
client=client,
|
||||
time_entries=time_entries,
|
||||
company_name="Your OPC",
|
||||
company_address="123 Business St, City, State 12345",
|
||||
company_email="billing@youropc.com",
|
||||
company_phone="+1 (555) 123-4567",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
# Backend tests
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Shared pytest fixtures for backend tests.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Set up environment variables before any imports during pytest collection
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-at-least-32-chars-long")
|
||||
os.environ.setdefault("ADMIN_USER", "testadmin")
|
||||
os.environ.setdefault("ADMIN_PASSWORD_HASH", "$pbkdf2-sha256$29000$N2YMIQQgBAAgxBgjhPD.vw$1234567890abcdef")
|
||||
os.environ.setdefault("VOLUME_ROOT", "/tmp/test-volume")
|
||||
os.environ.setdefault("DOCKER_HOST", "tcp://mock-docker:2375")
|
||||
os.environ.setdefault("GITEA_URL", "http://mock-gitea:3000")
|
||||
os.environ.setdefault("GITEA_TOKEN", "mock-token")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_volume_root(tmp_path):
|
||||
"""Create temporary volume root for testing."""
|
||||
volume_root = tmp_path / "volume1"
|
||||
volume_root.mkdir()
|
||||
(volume_root / "docker" / "nas-dashboard").mkdir(parents=True)
|
||||
return str(volume_root)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(temp_volume_root, monkeypatch):
|
||||
"""Mock config module with test values."""
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-at-least-32-chars-long")
|
||||
monkeypatch.setenv("ADMIN_USER", "testadmin")
|
||||
monkeypatch.setenv("ADMIN_PASSWORD_HASH", "$pbkdf2-sha256$29000$N2YMIQQgBAAgxBgjhPD.vw$1234567890abcdef")
|
||||
monkeypatch.setenv("VOLUME_ROOT", temp_volume_root)
|
||||
monkeypatch.setenv("DOCKER_HOST", "tcp://mock-docker:2375")
|
||||
monkeypatch.setenv("GITEA_URL", "http://mock-gitea:3000")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "mock-token")
|
||||
|
||||
# Reload config module to pick up new env vars
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_auth_file(temp_volume_root):
|
||||
"""Create temporary auth.json file."""
|
||||
auth_file = os.path.join(temp_volume_root, "docker/nas-dashboard/auth.json")
|
||||
data = {"totp_secret": "", "passkey_credentials": [], "token_version": 0, "last_totp_ts": 0}
|
||||
with open(auth_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
return auth_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_rbac_file(temp_volume_root):
|
||||
"""Create temporary rbac.json file."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
data = {
|
||||
"role_defaults": {
|
||||
"admin": {"pages": "*", "sidebar_links": "*"},
|
||||
"member": {"pages": ["dashboard", "docker"], "sidebar_links": "*"},
|
||||
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]},
|
||||
},
|
||||
"user_overrides": {},
|
||||
}
|
||||
with open(rbac_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
return rbac_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_access_token(mock_config):
|
||||
"""Generate a valid access token for testing."""
|
||||
from auth_service import create_access_token
|
||||
|
||||
return create_access_token(data={"sub": "testuser", "role": "admin"}, expires_delta=timedelta(minutes=30))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_refresh_token(mock_config, temp_auth_file):
|
||||
"""Generate a valid refresh token for testing."""
|
||||
from auth_service import create_refresh_token
|
||||
|
||||
return create_refresh_token(data={"sub": "testuser", "role": "admin"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_access_token(mock_config):
|
||||
"""Generate an expired access token for testing."""
|
||||
from auth_service import create_access_token
|
||||
|
||||
return create_access_token(data={"sub": "testuser", "role": "admin"}, expires_delta=timedelta(seconds=-1))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_docker_client():
|
||||
"""Mock Docker client for testing.
|
||||
|
||||
Returns a mock client with a single running container.
|
||||
For error scenarios, use mock_docker_client_with_errors.
|
||||
"""
|
||||
client = Mock()
|
||||
|
||||
# Mock container image
|
||||
mock_image = Mock()
|
||||
mock_image.tags = ["test-image:latest"]
|
||||
mock_image.id = "sha256:abc123def456"
|
||||
|
||||
# Mock container
|
||||
container = Mock()
|
||||
container.id = "abc123"
|
||||
container.short_id = "abc123"
|
||||
container.name = "test-container"
|
||||
container.status = "running"
|
||||
container.image = mock_image
|
||||
container.ports = {"80/tcp": [{"HostPort": "8080"}]}
|
||||
container.attrs = {"State": {"Health": {"Status": "healthy"}}, "Config": {"Image": "test-image:latest"}}
|
||||
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
||||
|
||||
client.containers.list.return_value = [container]
|
||||
client.containers.get.return_value = container
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_docker_client_with_errors():
|
||||
"""Mock Docker client that simulates error scenarios.
|
||||
|
||||
Use this fixture to test error handling:
|
||||
- Container not found (NotFound exception)
|
||||
- Connection errors (APIError)
|
||||
- Permission denied (APIError with 403)
|
||||
|
||||
Example:
|
||||
def test_container_not_found(test_app, admin_headers, mock_docker_client_with_errors):
|
||||
# Configure the mock to raise NotFound
|
||||
import docker.errors
|
||||
mock_docker_client_with_errors.containers.get.side_effect = docker.errors.NotFound("Container not found")
|
||||
"""
|
||||
import docker.errors
|
||||
|
||||
client = Mock()
|
||||
|
||||
# By default, raise NotFound for get operations
|
||||
client.containers.get.side_effect = docker.errors.NotFound("Container not found")
|
||||
client.containers.list.return_value = []
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_docker_client_factory():
|
||||
"""Factory for creating customizable Docker client mocks.
|
||||
|
||||
Returns a function that creates mock clients with specific configurations.
|
||||
|
||||
Example:
|
||||
def test_custom_container(mock_docker_client_factory):
|
||||
client = mock_docker_client_factory(
|
||||
container_name="my-container",
|
||||
container_status="stopped",
|
||||
image_tag="my-image:v1.0"
|
||||
)
|
||||
"""
|
||||
|
||||
def _create_mock(
|
||||
container_name="test-container",
|
||||
container_status="running",
|
||||
image_tag="test-image:latest",
|
||||
container_id="abc123",
|
||||
):
|
||||
client = Mock()
|
||||
|
||||
# Mock container image
|
||||
mock_image = Mock()
|
||||
mock_image.tags = [image_tag]
|
||||
mock_image.id = f"sha256:{container_id}def456"
|
||||
|
||||
# Mock container
|
||||
container = Mock()
|
||||
container.id = container_id
|
||||
container.short_id = container_id[:12]
|
||||
container.name = container_name
|
||||
container.status = container_status
|
||||
container.image = mock_image
|
||||
container.ports = {"80/tcp": [{"HostPort": "8080"}]}
|
||||
container.attrs = {"State": {"Health": {"Status": "healthy"}}, "Config": {"Image": image_tag}}
|
||||
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
||||
|
||||
client.containers.list.return_value = [container]
|
||||
client.containers.get.return_value = container
|
||||
|
||||
return client
|
||||
|
||||
return _create_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ssh_connection():
|
||||
"""Mock asyncssh connection for testing."""
|
||||
conn = AsyncMock()
|
||||
process = AsyncMock()
|
||||
process.stdout = AsyncMock()
|
||||
process.stdin = AsyncMock()
|
||||
process.stderr = AsyncMock()
|
||||
conn.create_process.return_value = process
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_httpx_client():
|
||||
"""Mock httpx AsyncClient for testing."""
|
||||
client = AsyncMock()
|
||||
response = AsyncMock()
|
||||
response.status_code = 200
|
||||
response.json.return_value = {"status": "ok"}
|
||||
client.get.return_value = response
|
||||
client.post.return_value = response
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client):
|
||||
"""Create FastAPI test client with mocked dependencies."""
|
||||
# Reload routers to pick up new config values
|
||||
import importlib
|
||||
|
||||
import routers.files
|
||||
|
||||
importlib.reload(routers.files)
|
||||
|
||||
# Patch Docker client before importing main
|
||||
with patch("docker.DockerClient", return_value=mock_docker_client):
|
||||
# Mock the limiter to disable rate limiting during tests
|
||||
mock_limiter = Mock()
|
||||
mock_limiter.limit = lambda *args, **kwargs: lambda func: func
|
||||
|
||||
with patch("slowapi.Limiter", return_value=mock_limiter):
|
||||
from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_headers(valid_access_token):
|
||||
"""Headers with admin access token."""
|
||||
return {"Authorization": f"Bearer {valid_access_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rbac_data():
|
||||
"""Sample RBAC configuration for testing."""
|
||||
return {
|
||||
"role_defaults": {
|
||||
"admin": {"pages": "*", "sidebar_links": "*"},
|
||||
"member": {"pages": ["dashboard", "docker", "files"], "sidebar_links": "*"},
|
||||
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]},
|
||||
},
|
||||
"user_overrides": {
|
||||
"customuser": {"pages": ["dashboard", "docker"], "sidebar_links": ["dashboard", "docker", "files"]}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_totp_secret():
|
||||
"""Sample TOTP secret for testing."""
|
||||
return "JBSWY3DPEHPK3PXP" # Base32 encoded secret
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Mock FastAPI Request object."""
|
||||
request = Mock()
|
||||
request.client = Mock()
|
||||
request.client.host = "192.168.31.100"
|
||||
request.headers = {}
|
||||
return request
|
||||
@@ -0,0 +1 @@
|
||||
# Integration tests
|
||||
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
Integration tests for authentication flow.
|
||||
"""
|
||||
|
||||
import pyotp
|
||||
|
||||
|
||||
class TestLoginFlow:
|
||||
"""Test login endpoint."""
|
||||
|
||||
def test_login_success_no_2fa(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test successful login without 2FA."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
# Set up password
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user"] == "testadmin"
|
||||
assert data["role"] == "admin"
|
||||
assert data["has_2fa"] is False
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_login_success_with_2fa(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test successful login with 2FA."""
|
||||
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
totp = pyotp.TOTP(sample_totp_secret)
|
||||
valid_code = totp.now()
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": valid_code}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user"] == "testadmin"
|
||||
assert data["has_2fa"] is True
|
||||
|
||||
def test_login_wrong_password(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test login with wrong password."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
password = "correct_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login", json={"username": "testadmin", "password": "wrong_password", "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in response.json()["detail"]
|
||||
|
||||
def test_login_wrong_username(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test login with wrong username."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login", json={"username": "wronguser", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_missing_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test login with 2FA enabled but no code provided."""
|
||||
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "2FA code required" in response.json()["detail"]
|
||||
|
||||
def test_login_invalid_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test login with invalid 2FA code."""
|
||||
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": "000000"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_sets_cookies(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test that login sets auth cookies."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Check that cookies are set
|
||||
assert "nas_access_token" in response.cookies
|
||||
assert "nas_refresh_token" in response.cookies
|
||||
|
||||
|
||||
class TestRefreshFlow:
|
||||
"""Test token refresh endpoint."""
|
||||
|
||||
def test_refresh_with_valid_token(self, test_app, mock_config, temp_auth_file, valid_refresh_token):
|
||||
"""Test refreshing with valid refresh token."""
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_refresh_token})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["access_token"] != valid_refresh_token
|
||||
|
||||
def test_refresh_with_cookie(self, test_app, mock_config, temp_auth_file, valid_refresh_token):
|
||||
"""Test refreshing with refresh token in cookie."""
|
||||
test_app.cookies.set("nas_refresh_token", valid_refresh_token)
|
||||
|
||||
response = test_app.post("/api/auth/refresh", json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
|
||||
def test_refresh_with_invalid_token(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test refreshing with invalid token."""
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": "invalid.token.here"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_refresh_with_expired_token(self, test_app, mock_config, temp_auth_file, expired_access_token):
|
||||
"""Test refreshing with expired token."""
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": expired_access_token})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_refresh_missing_token(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test refresh without providing token."""
|
||||
response = test_app.post("/api/auth/refresh", json={})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Missing refresh token" in response.json()["detail"]
|
||||
|
||||
def test_refresh_with_access_token_fails(self, test_app, mock_config, temp_auth_file, valid_access_token):
|
||||
"""Test that access token cannot be used for refresh."""
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_access_token})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token type" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestLogoutFlow:
|
||||
"""Test logout endpoint."""
|
||||
|
||||
def test_logout_success(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test successful logout."""
|
||||
response = test_app.post("/api/auth/logout", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_logout_clears_cookies(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test that logout clears auth cookies."""
|
||||
response = test_app.post("/api/auth/logout", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Cookies should be deleted (max-age=0 or expires in past)
|
||||
# TestClient doesn't fully simulate cookie deletion, but we can check response
|
||||
|
||||
def test_logout_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test logout without authentication - should succeed as logout doesn't require auth."""
|
||||
response = test_app.post("/api/auth/logout")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
|
||||
class TestUserInfo:
|
||||
"""Test user info endpoint."""
|
||||
|
||||
def test_get_user_info(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test getting current user info."""
|
||||
response = test_app.get("/api/auth/me", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["username"] == "testuser"
|
||||
assert data["role"] == "admin"
|
||||
assert "pages" in data
|
||||
assert "sidebar_links" in data
|
||||
|
||||
def test_get_user_info_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test getting user info without authentication."""
|
||||
response = test_app.get("/api/auth/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestProxyAuth:
|
||||
"""Test proxy authentication endpoint."""
|
||||
|
||||
def test_proxy_auth_from_trusted_source(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test proxy auth from trusted proxy."""
|
||||
response = test_app.get(
|
||||
"/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"}
|
||||
)
|
||||
|
||||
# Note: This test may need adjustment based on actual proxy auth implementation
|
||||
# The test client's default host may not be in trusted proxies
|
||||
assert response.status_code in [200, 403]
|
||||
|
||||
def test_proxy_auth_from_untrusted_source(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test proxy auth from untrusted source."""
|
||||
# Mock request from untrusted IP
|
||||
response = test_app.get(
|
||||
"/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"}
|
||||
)
|
||||
|
||||
# Should reject untrusted proxy
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestPasswordChange:
|
||||
"""Test password change endpoint."""
|
||||
|
||||
def test_change_password_success(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test successful password change."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
old_password = "old_password"
|
||||
new_password = "new_password_123"
|
||||
|
||||
# Set initial password
|
||||
hashed = get_password_hash(old_password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
headers=admin_headers,
|
||||
json={"current_password": old_password, "new_password": new_password},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_change_password_wrong_old_password(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test password change with wrong old password."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
old_password = "old_password"
|
||||
hashed = get_password_hash(old_password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
headers=admin_headers,
|
||||
json={"current_password": "wrong_password", "new_password": "new_password_123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_change_password_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test password change without authentication."""
|
||||
response = test_app.post("/api/auth/change-password", json={"current_password": "old", "new_password": "new"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_change_password_too_short(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test password change with password too short."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
old_password = "old_password"
|
||||
hashed = get_password_hash(old_password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
headers=admin_headers,
|
||||
json={"current_password": old_password, "new_password": "short"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "at least 8 characters" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestRBACConfig:
|
||||
"""Test RBAC configuration endpoints."""
|
||||
|
||||
def test_get_rbac_config_as_admin(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test getting RBAC config as admin."""
|
||||
response = test_app.get("/api/auth/rbac/config", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "role_defaults" in data or "user_overrides" in data
|
||||
|
||||
def test_get_rbac_config_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot get RBAC config."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.get("/api/auth/rbac/config", headers=headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_get_rbac_config_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test getting RBAC config without authentication."""
|
||||
response = test_app.get("/api/auth/rbac/config")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_set_user_override(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test setting user page override."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/overrides/testuser", headers=admin_headers, json={"pages": ["overview", "docker"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_set_user_override_missing_pages(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test setting user override without pages field."""
|
||||
response = test_app.put("/api/auth/rbac/overrides/testuser", headers=admin_headers, json={})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing pages field" in response.json()["detail"]
|
||||
|
||||
def test_set_user_override_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot set user override."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.put("/api/auth/rbac/overrides/testuser", headers=headers, json={"pages": ["overview"]})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_delete_user_override(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test deleting user override."""
|
||||
# First set an override
|
||||
test_app.put("/api/auth/rbac/overrides/testuser", headers=admin_headers, json={"pages": ["overview"]})
|
||||
|
||||
# Then delete it
|
||||
response = test_app.delete("/api/auth/rbac/overrides/testuser", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_delete_user_override_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot delete user override."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.delete("/api/auth/rbac/overrides/testuser", headers=headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_update_role_config(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test updating role configuration."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/roles/member",
|
||||
headers=admin_headers,
|
||||
json={"pages": ["overview", "docker"], "sidebar_links": ["navidrome", "jellyfin"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_update_role_config_with_wildcard(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test updating role with wildcard pages."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/roles/admin", headers=admin_headers, json={"pages": "*", "sidebar_links": "*"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_update_role_config_missing_pages(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test updating role without pages field."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/roles/member", headers=admin_headers, json={"sidebar_links": ["navidrome"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing pages field" in response.json()["detail"]
|
||||
|
||||
def test_update_role_config_invalid_pages_type(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test updating role with invalid pages type."""
|
||||
response = test_app.put("/api/auth/rbac/roles/member", headers=admin_headers, json={"pages": "invalid"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "pages must be" in response.json()["detail"]
|
||||
|
||||
def test_update_role_config_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot update role config."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.put("/api/auth/rbac/roles/member", headers=headers, json={"pages": ["overview"]})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestPreferences:
|
||||
"""Test user preferences endpoints."""
|
||||
|
||||
def test_get_preferences(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test getting user preferences."""
|
||||
response = test_app.get("/api/auth/preferences", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "sidebar_order" in data
|
||||
|
||||
def test_get_preferences_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test getting preferences without authentication."""
|
||||
response = test_app.get("/api/auth/preferences")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_save_preferences(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test saving user preferences."""
|
||||
response = test_app.put(
|
||||
"/api/auth/preferences",
|
||||
headers=admin_headers,
|
||||
json={"sidebar_order": {"main": ["overview", "docker", "files"], "media": ["navidrome", "jellyfin"]}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_save_preferences_missing_sidebar_order(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test saving preferences without sidebar_order."""
|
||||
response = test_app.put("/api/auth/preferences", headers=admin_headers, json={})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing sidebar_order" in response.json()["detail"]
|
||||
|
||||
def test_save_preferences_invalid_type(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test saving preferences with invalid type."""
|
||||
response = test_app.put("/api/auth/preferences", headers=admin_headers, json={"sidebar_order": "invalid"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing sidebar_order dict" in response.json()["detail"]
|
||||
|
||||
def test_save_preferences_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test saving preferences without authentication."""
|
||||
response = test_app.put("/api/auth/preferences", json={"sidebar_order": {}})
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Integration tests for chat summary operations.
|
||||
"""
|
||||
import pytest
|
||||
import aiosqlite
|
||||
import os
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
|
||||
class TestChatSummaryDates:
|
||||
"""Test chat summary dates endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dates_empty(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test listing dates when database is empty."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create empty database with schema
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)")
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/dates", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dates_with_data(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test listing dates with summaries."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create database with test data
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)")
|
||||
await db.execute(
|
||||
"INSERT INTO summaries VALUES (?, ?, ?)", ("2024-01-01", "Summary 1", "2024-01-01T10:00:00")
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO summaries VALUES (?, ?, ?)", ("2024-01-02", "Summary 2", "2024-01-02T10:00:00")
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/dates", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
assert "2024-01-02" in data
|
||||
assert "2024-01-01" in data
|
||||
|
||||
def test_list_dates_without_auth(self, test_app, mock_config):
|
||||
"""Test listing dates without authentication."""
|
||||
response = test_app.get("/api/chat-summary/dates")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestChatSummaryGet:
|
||||
"""Test get summary endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_summary_success(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test getting a summary for a specific date."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create database with test data
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)")
|
||||
await db.execute(
|
||||
"INSERT INTO summaries VALUES (?, ?, ?)",
|
||||
("2024-01-01", "Test summary content", "2024-01-01T10:00:00"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/summary/2024-01-01", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["date"] == "2024-01-01"
|
||||
assert data["content"] == "Test summary content"
|
||||
assert data["created_at"] == "2024-01-01T10:00:00"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_summary_not_found(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test getting a summary for a date that doesn't exist."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create empty database
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)")
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/summary/2024-01-01", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "No summary for this date" in data["detail"]
|
||||
|
||||
def test_get_summary_without_auth(self, test_app, mock_config):
|
||||
"""Test getting summary without authentication."""
|
||||
response = test_app.get("/api/chat-summary/summary/2024-01-01")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestChatSummaryMessages:
|
||||
"""Test get messages endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_success(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test getting messages for a specific date."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create database with test data
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE messages (group_name TEXT, sender_name TEXT, text TEXT, timestamp TEXT)")
|
||||
await db.execute(
|
||||
"INSERT INTO messages VALUES (?, ?, ?, ?)",
|
||||
("Group1", "User1", "Hello", "2024-01-01 10:00:00"),
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO messages VALUES (?, ?, ?, ?)",
|
||||
("Group1", "User2", "Hi there", "2024-01-01 10:01:00"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/messages/2024-01-01", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["group"] == "Group1"
|
||||
assert data[0]["sender"] == "User1"
|
||||
assert data[0]["text"] == "Hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_empty(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test getting messages when none exist for date."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create empty database
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE messages (group_name TEXT, sender_name TEXT, text TEXT, timestamp TEXT)")
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/messages/2024-01-01", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 0
|
||||
|
||||
def test_get_messages_without_auth(self, test_app, mock_config):
|
||||
"""Test getting messages without authentication."""
|
||||
response = test_app.get("/api/chat-summary/messages/2024-01-01")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestChatSummaryTrigger:
|
||||
"""Test trigger summary endpoint."""
|
||||
|
||||
def test_trigger_summary_success(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test triggering a summary generation as admin."""
|
||||
trigger_path = tmp_path / "trigger"
|
||||
|
||||
with patch.dict(os.environ, {"CHAT_SUMMARY_TRIGGER": str(trigger_path)}):
|
||||
response = test_app.post("/api/chat-summary/trigger", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "triggered"
|
||||
assert trigger_path.exists()
|
||||
assert trigger_path.read_text() == "1"
|
||||
|
||||
def test_trigger_summary_creates_directory(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test that trigger creates parent directory if needed."""
|
||||
trigger_path = tmp_path / "subdir" / "trigger"
|
||||
|
||||
with patch.dict(os.environ, {"CHAT_SUMMARY_TRIGGER": str(trigger_path)}):
|
||||
response = test_app.post("/api/chat-summary/trigger", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert trigger_path.parent.exists()
|
||||
assert trigger_path.exists()
|
||||
|
||||
def test_trigger_summary_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file, tmp_path):
|
||||
"""Test that non-admin cannot trigger summary."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30))
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
trigger_path = tmp_path / "trigger"
|
||||
with patch.dict(os.environ, {"CHAT_SUMMARY_TRIGGER": str(trigger_path)}):
|
||||
response = test_app.post("/api/chat-summary/trigger", headers=headers)
|
||||
|
||||
# Page-level RBAC blocks access before endpoint-level check
|
||||
assert response.status_code == 403
|
||||
assert "denied" in response.json()["detail"].lower()
|
||||
|
||||
def test_trigger_summary_invalid_path(self, test_app, mock_config, admin_headers):
|
||||
"""Test that invalid trigger paths are rejected."""
|
||||
# Try to write outside /app/data/
|
||||
with patch.dict(os.environ, {"CHAT_SUMMARY_TRIGGER": "/etc/passwd"}):
|
||||
response = test_app.post("/api/chat-summary/trigger", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid trigger path" in response.json()["detail"]
|
||||
|
||||
def test_trigger_summary_without_auth(self, test_app, mock_config):
|
||||
"""Test triggering summary without authentication."""
|
||||
response = test_app.post("/api/chat-summary/trigger")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Integration tests for Docker operations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDockerContainerList:
|
||||
"""Test listing Docker containers."""
|
||||
|
||||
def test_list_containers_success(self, test_app, admin_headers):
|
||||
"""Test listing containers successfully."""
|
||||
response = test_app.get("/api/docker/containers", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
if len(data) > 0:
|
||||
container = data[0]
|
||||
assert "id" in container
|
||||
assert "name" in container
|
||||
assert "status" in container
|
||||
|
||||
def test_list_containers_without_auth(self, test_app):
|
||||
"""Test listing containers without authentication."""
|
||||
response = test_app.get("/api/docker/containers")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestDockerContainerActions:
|
||||
"""Test Docker container start/stop/restart actions."""
|
||||
|
||||
def test_start_container_success(self, test_app, admin_headers, mock_docker_client):
|
||||
"""Test starting a container."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.post(f"/api/docker/containers/{container_id}/start", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_stop_container_success(self, test_app, admin_headers, mock_docker_client):
|
||||
"""Test stopping a container."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.post(f"/api/docker/containers/{container_id}/stop", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_restart_container_success(self, test_app, admin_headers, mock_docker_client):
|
||||
"""Test restarting a container."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.post(f"/api/docker/containers/{container_id}/restart", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_container_action_without_auth(self, test_app):
|
||||
"""Test container action without authentication."""
|
||||
response = test_app.post("/api/docker/containers/abc123/start")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_container_action_invalid_action(self, test_app, admin_headers):
|
||||
"""Test container action with invalid action."""
|
||||
response = test_app.post("/api/docker/containers/abc123/invalid_action", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
|
||||
|
||||
class TestDockerContainerLogs:
|
||||
"""Test Docker container logs retrieval."""
|
||||
|
||||
def test_get_container_logs_success(self, test_app, admin_headers, mock_docker_client):
|
||||
"""Test getting container logs."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.get(f"/api/docker/containers/{container_id}/logs", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "logs" in data or isinstance(data, str)
|
||||
|
||||
def test_get_container_logs_with_tail(self, test_app, admin_headers, mock_docker_client):
|
||||
"""Test getting container logs with tail limit."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.get(f"/api/docker/containers/{container_id}/logs?tail=100", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_get_container_logs_without_auth(self, test_app):
|
||||
"""Test getting logs without authentication."""
|
||||
response = test_app.get("/api/docker/containers/abc123/logs")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_get_container_logs_nonexistent(self, test_app, admin_headers, mock_docker_client):
|
||||
"""Test getting logs for nonexistent container."""
|
||||
# Note: In this test setup, the mock always returns a container
|
||||
# Testing actual error handling would require a different approach
|
||||
# For now, verify the endpoint works with the mock
|
||||
response = test_app.get("/api/docker/containers/nonexistent/logs", headers=admin_headers)
|
||||
|
||||
# With the current mock setup, this will succeed
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestDockerPermissions:
|
||||
"""Test Docker operation permissions."""
|
||||
|
||||
@pytest.fixture
|
||||
def member_token(self, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Create token for member role."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
return create_access_token(data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30))
|
||||
|
||||
@pytest.fixture
|
||||
def member_headers(self, member_token):
|
||||
"""Headers with member access token."""
|
||||
return {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
def test_member_can_list_containers(self, test_app, member_headers):
|
||||
"""Test that member can list containers."""
|
||||
response = test_app.get("/api/docker/containers", headers=member_headers)
|
||||
|
||||
# Members should be able to view containers
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_member_cannot_start_container(self, test_app, member_headers):
|
||||
"""Test that member cannot start containers (admin only)."""
|
||||
response = test_app.post("/api/docker/containers/abc123/start", headers=member_headers)
|
||||
|
||||
# Should be forbidden for non-admin
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_viewer_can_list_containers(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that viewer can list containers if they have docker page access."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
viewer_token = create_access_token(
|
||||
data={"sub": "vieweruser", "role": "viewer"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {viewer_token}"}
|
||||
|
||||
response = test_app.get("/api/docker/containers", headers=headers)
|
||||
|
||||
# Viewer may not have docker page access by default
|
||||
assert response.status_code in [200, 403]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Integration tests for file operations.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class TestFileBrowse:
|
||||
"""Test file browsing."""
|
||||
|
||||
def test_browse_root(self, test_app, admin_headers, temp_volume_root):
|
||||
"""Test browsing root directory."""
|
||||
response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "entries" in data or isinstance(data, list)
|
||||
|
||||
def test_browse_without_auth(self, test_app):
|
||||
"""Test browsing without authentication."""
|
||||
response = test_app.get("/api/files/browse", params={"path": "/"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_browse_blocked_directory(self, test_app, admin_headers):
|
||||
"""Test browsing blocked directory."""
|
||||
response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/@appstore"})
|
||||
|
||||
# Should be forbidden
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_browse_path_traversal_attempt(self, test_app, admin_headers):
|
||||
"""Test path traversal protection."""
|
||||
response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/../../../etc"})
|
||||
|
||||
# Should be forbidden
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestFileUpload:
|
||||
"""Test file upload."""
|
||||
|
||||
def test_upload_file_success(self, test_app, admin_headers, temp_volume_root):
|
||||
"""Test uploading a file."""
|
||||
test_content = b"test file content"
|
||||
files = {"file": ("test.txt", test_content, "text/plain")}
|
||||
|
||||
response = test_app.post("/api/files/upload", headers=admin_headers, files=files, params={"path": "/"})
|
||||
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
def test_upload_without_auth(self, test_app):
|
||||
"""Test upload without authentication."""
|
||||
files = {"file": ("test.txt", b"content", "text/plain")}
|
||||
|
||||
response = test_app.post("/api/files/upload", files=files, params={"path": "/"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_upload_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot upload."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
files = {"file": ("test.txt", b"content", "text/plain")}
|
||||
|
||||
response = test_app.post("/api/files/upload", headers=headers, files=files, params={"path": "/"})
|
||||
|
||||
# Upload should be admin-only
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestFileDelete:
|
||||
"""Test file deletion."""
|
||||
|
||||
def test_delete_file_success(self, test_app, admin_headers, temp_volume_root):
|
||||
"""Test deleting a file."""
|
||||
# Create a test file first
|
||||
test_file = os.path.join(temp_volume_root, "test_delete.txt")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("test content")
|
||||
|
||||
response = test_app.delete("/api/files/delete", headers=admin_headers, params={"path": "/test_delete.txt"})
|
||||
|
||||
assert response.status_code in [200, 204]
|
||||
|
||||
def test_delete_without_auth(self, test_app):
|
||||
"""Test delete without authentication."""
|
||||
response = test_app.delete("/api/files/delete", params={"path": "/test.txt"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_delete_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot delete."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.delete("/api/files/delete", headers=headers, params={"path": "/test.txt"})
|
||||
|
||||
# Delete should be admin-only
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestFileDownload:
|
||||
"""Test file download."""
|
||||
|
||||
def test_download_file_success(self, test_app, admin_headers, temp_volume_root):
|
||||
"""Test downloading a file."""
|
||||
# Create a test file
|
||||
test_file = os.path.join(temp_volume_root, "test_download.txt")
|
||||
test_content = "test download content"
|
||||
with open(test_file, "w") as f:
|
||||
f.write(test_content)
|
||||
|
||||
response = test_app.get("/api/files/download", headers=admin_headers, params={"path": "/test_download.txt"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert test_content in response.text or response.content == test_content.encode()
|
||||
|
||||
def test_download_without_auth(self, test_app):
|
||||
"""Test download without authentication."""
|
||||
response = test_app.get("/api/files/download", params={"path": "/test.txt"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_download_nonexistent_file(self, test_app, admin_headers):
|
||||
"""Test downloading nonexistent file."""
|
||||
response = test_app.get("/api/files/download", headers=admin_headers, params={"path": "/nonexistent.txt"})
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Integration tests for Gitea operations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestGiteaRepos:
|
||||
"""Test Gitea repository endpoints."""
|
||||
|
||||
@pytest.mark.skip(reason="Requires external Gitea API - tested manually")
|
||||
def test_list_repos(self, test_app, admin_headers):
|
||||
"""Test listing Gitea repositories."""
|
||||
response = test_app.get("/api/gitea/repos", headers=admin_headers)
|
||||
# This would require a real Gitea instance
|
||||
assert response.status_code in [200, 500, 503]
|
||||
|
||||
def test_list_repos_without_auth(self, test_app):
|
||||
"""Test listing repos without authentication."""
|
||||
response = test_app.get("/api/gitea/repos")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestGiteaCommits:
|
||||
"""Test Gitea commits endpoint."""
|
||||
|
||||
@pytest.mark.skip(reason="Requires external Gitea API - tested manually")
|
||||
def test_list_commits(self, test_app, admin_headers):
|
||||
"""Test listing commits for a repository."""
|
||||
response = test_app.get("/api/gitea/repos/owner/repo/commits", headers=admin_headers)
|
||||
# This would require a real Gitea instance
|
||||
assert response.status_code in [200, 404, 500, 503]
|
||||
|
||||
def test_list_commits_invalid_owner(self, test_app, admin_headers):
|
||||
"""Test listing commits with invalid owner name."""
|
||||
response = test_app.get("/api/gitea/repos/invalid@owner/repo/commits", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "invalid" in data["detail"].lower()
|
||||
|
||||
def test_list_commits_invalid_repo(self, test_app, admin_headers):
|
||||
"""Test listing commits with invalid repo name."""
|
||||
response = test_app.get("/api/gitea/repos/owner/invalid@repo/commits", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "invalid" in data["detail"].lower()
|
||||
|
||||
def test_list_commits_special_chars_in_owner(self, test_app, admin_headers):
|
||||
"""Test listing commits with special characters in owner."""
|
||||
response = test_app.get("/api/gitea/repos/owner$/repo/commits", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_list_commits_special_chars_in_repo(self, test_app, admin_headers):
|
||||
"""Test listing commits with special characters in repo."""
|
||||
response = test_app.get("/api/gitea/repos/owner/repo!/commits", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.skip(reason="Requires external Gitea API - tested manually")
|
||||
def test_list_commits_valid_names(self, test_app, admin_headers):
|
||||
"""Test that valid names with allowed characters pass validation."""
|
||||
# Valid characters: a-zA-Z0-9._-
|
||||
# This will fail at the HTTP call level, but should pass validation
|
||||
response = test_app.get("/api/gitea/repos/valid-owner_123/repo.name-456/commits", headers=admin_headers)
|
||||
|
||||
# Should not be 400 (validation error), but may be 500/503 (HTTP error)
|
||||
assert response.status_code != 400
|
||||
|
||||
def test_list_commits_without_auth(self, test_app):
|
||||
"""Test listing commits without authentication."""
|
||||
response = test_app.get("/api/gitea/repos/owner/repo/commits")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Integration tests for LiteLLM health check operations.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
class TestLiteLLMHealth:
|
||||
"""Test LiteLLM health check endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_success(self, test_app, mock_config, admin_headers, monkeypatch):
|
||||
"""Test successful health check."""
|
||||
monkeypatch.setenv("LITELLM_URL", "http://mock-litellm:4000")
|
||||
monkeypatch.setenv("LITELLM_HEALTH_API_KEY", "")
|
||||
|
||||
# Mock successful response
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
|
||||
|
||||
response = test_app.get("/api/litellm/health", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["status"] == "up"
|
||||
assert "latency_ms" in data
|
||||
assert data["http_status"] == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_auth_required(self, test_app, mock_config, admin_headers, monkeypatch):
|
||||
"""Test health check when auth is required but not provided."""
|
||||
monkeypatch.setenv("LITELLM_URL", "http://mock-litellm:4000")
|
||||
monkeypatch.setenv("LITELLM_HEALTH_API_KEY", "")
|
||||
|
||||
# Mock 401 response without API key
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = False
|
||||
mock_response.status_code = 401
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
|
||||
|
||||
response = test_app.get("/api/litellm/health", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["status"] == "auth_required"
|
||||
assert data["http_status"] == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_connection_error(self, test_app, mock_config, admin_headers, monkeypatch):
|
||||
"""Test health check when connection fails."""
|
||||
monkeypatch.setenv("LITELLM_URL", "http://mock-litellm:4000")
|
||||
monkeypatch.setenv("LITELLM_HEALTH_API_KEY", "")
|
||||
|
||||
# Mock connection error
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get.side_effect = Exception("Connection refused")
|
||||
|
||||
response = test_app.get("/api/litellm/health", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is False
|
||||
assert data["status"] == "down"
|
||||
assert "Connection refused" in data["error"]
|
||||
|
||||
def test_health_check_without_auth(self, test_app, mock_config):
|
||||
"""Test health check without authentication."""
|
||||
response = test_app.get("/api/litellm/health")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Integration tests for main application setup.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAppStartup:
|
||||
"""Test application initialization."""
|
||||
|
||||
def test_app_exists(self, test_app):
|
||||
"""Test that the FastAPI app is created."""
|
||||
assert test_app is not None
|
||||
|
||||
def test_root_endpoint(self, test_app):
|
||||
"""Test root endpoint redirects or returns info."""
|
||||
response = test_app.get("/")
|
||||
# Root may redirect or return 404, just verify app responds
|
||||
assert response.status_code in [200, 404, 307, 308]
|
||||
|
||||
def test_health_endpoint(self, test_app):
|
||||
"""Test health check endpoint if it exists."""
|
||||
response = test_app.get("/health")
|
||||
# May not exist, just checking app handles unknown routes
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
def test_api_prefix_exists(self, test_app):
|
||||
"""Test that API routes are mounted under /api."""
|
||||
# Test a known endpoint exists under /api
|
||||
response = test_app.get("/api/auth/me")
|
||||
# Should return 401 (auth required) not 404 (route not found)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_cors_headers(self, test_app):
|
||||
"""Test CORS middleware is configured."""
|
||||
response = test_app.options("/api/auth/me")
|
||||
# OPTIONS request should be handled
|
||||
assert response.status_code in [200, 405]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Integration tests for OPC resource-level authorization"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_list_tasks_filters_by_user(test_app, admin_headers):
|
||||
"""Test that users only see tasks they have access to"""
|
||||
# Create a task as admin
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Admin Task", "description": "Only admin should see this", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
admin_task_id = response.json()["id"]
|
||||
|
||||
# List tasks as admin - should see the task
|
||||
response = test_app.get("/api/opc/tasks", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
task_ids = [t["id"] for t in response.json()["items"]]
|
||||
assert admin_task_id in task_ids
|
||||
|
||||
|
||||
def test_viewer_cannot_create_task(test_app, admin_headers):
|
||||
"""Test that viewers cannot create tasks"""
|
||||
# This would require a viewer token - for now we test that admin can create
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Test Task", "description": "Test", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200 # Admin can create
|
||||
|
||||
|
||||
def test_cannot_modify_others_task(test_app, admin_headers):
|
||||
"""Test that users cannot modify tasks they don't own (unless admin)"""
|
||||
# Create a task
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Test Task", "description": "Test", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
task_id = response.json()["id"]
|
||||
|
||||
# Admin can modify their own task
|
||||
response = test_app.put(f"/api/opc/tasks/{task_id}", json={"title": "Updated Task"}, headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_cannot_delete_others_task(test_app, admin_headers):
|
||||
"""Test that users cannot delete tasks they don't own (unless admin)"""
|
||||
# Create a task
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Test Task", "description": "Test", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
task_id = response.json()["id"]
|
||||
|
||||
# Admin can delete their own task
|
||||
response = test_app.delete(f"/api/opc/tasks/{task_id}", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_member_can_trigger_pm_agent(test_app, admin_headers):
|
||||
"""Test that members can trigger PM agent (auto-approval)"""
|
||||
# Create a task
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Test Task", "description": "Test", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
task_id = response.json()["id"]
|
||||
|
||||
# Trigger PM agent
|
||||
response = test_app.post(f"/api/opc/agents/pm/execute?task_id={task_id}", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_only_admin_can_approve_execution(test_app, admin_headers):
|
||||
"""Test that only admins can approve CxO-level executions"""
|
||||
# Create a task
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Test Task", "description": "Test", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
task_id = response.json()["id"]
|
||||
|
||||
# Trigger CTO agent (requires approval)
|
||||
response = test_app.post(f"/api/opc/agents/cto/execute?task_id={task_id}", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
execution_id = response.json()["id"]
|
||||
|
||||
# Admin can approve
|
||||
response = test_app.post(
|
||||
f"/api/opc/executions/{execution_id}/approve", json={"approved": True}, headers=admin_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_task_creator_tracked_in_metadata(test_app, admin_headers):
|
||||
"""Test that task creator is tracked in metadata"""
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Test Task", "description": "Test", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
task = response.json()
|
||||
|
||||
# Check metadata contains created_by
|
||||
assert "metadata" in task
|
||||
assert task["metadata"]["created_by"] == "admin"
|
||||
|
||||
|
||||
def test_cannot_view_others_task_details(test_app, admin_headers):
|
||||
"""Test that users cannot view task details they don't have access to"""
|
||||
# Create a task
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Test Task", "description": "Test", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
task_id = response.json()["id"]
|
||||
|
||||
# Admin can view their own task
|
||||
response = test_app.get(f"/api/opc/tasks/{task_id}", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_executions_filtered_by_task_access(test_app, admin_headers):
|
||||
"""Test that executions are filtered based on task access"""
|
||||
# Create a task
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Test Task", "description": "Test", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
task_id = response.json()["id"]
|
||||
|
||||
# Trigger agent
|
||||
response = test_app.post(f"/api/opc/agents/pm/execute?task_id={task_id}", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
execution_id = response.json()["id"]
|
||||
|
||||
# List executions - should see the execution
|
||||
response = test_app.get("/api/opc/executions", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
execution_ids = [e["id"] for e in response.json()["items"]]
|
||||
assert execution_id in execution_ids
|
||||
|
||||
|
||||
def test_cannot_view_others_execution_details(test_app, admin_headers):
|
||||
"""Test that users cannot view execution details for tasks they don't have access to"""
|
||||
# Create a task
|
||||
response = test_app.post(
|
||||
"/api/opc/tasks",
|
||||
json={"title": "Test Task", "description": "Test", "status": "parking_lot"},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
task_id = response.json()["id"]
|
||||
|
||||
# Trigger agent
|
||||
response = test_app.post(f"/api/opc/agents/pm/execute?task_id={task_id}", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
execution_id = response.json()["id"]
|
||||
|
||||
# Admin can view their own execution
|
||||
response = test_app.get(f"/api/opc/executions/{execution_id}", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Integration tests for passkey router"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_passkey_register_options_requires_auth(test_app):
|
||||
"""Test passkey registration options requires authentication"""
|
||||
response = test_app.post("/api/auth/passkey/register/options")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_passkey_register_options_success(test_app, admin_headers):
|
||||
"""Test passkey registration options returns challenge"""
|
||||
response = test_app.post("/api/auth/passkey/register/options", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "challenge" in data
|
||||
assert "rp" in data
|
||||
assert "user" in data
|
||||
|
||||
|
||||
def test_passkey_register_verify_requires_auth(test_app):
|
||||
"""Test passkey registration verify requires authentication"""
|
||||
response = test_app.post("/api/auth/passkey/register/verify", json={})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_passkey_login_options_no_auth_required(test_app):
|
||||
"""Test passkey login options does not require authentication"""
|
||||
response = test_app.post("/api/auth/passkey/login/options")
|
||||
# Should return 404 if no passkeys registered, not 401
|
||||
assert response.status_code in (200, 404)
|
||||
|
||||
|
||||
def test_passkey_login_options_returns_challenge(test_app, admin_headers):
|
||||
"""Test passkey login options returns challenge when passkeys exist"""
|
||||
# First register a passkey (would need full WebAuthn flow)
|
||||
# For now, test that endpoint exists
|
||||
response = test_app.post("/api/auth/passkey/login/options")
|
||||
assert response.status_code in (200, 404)
|
||||
|
||||
|
||||
def test_passkey_list_requires_auth(test_app):
|
||||
"""Test passkey list requires authentication"""
|
||||
response = test_app.get("/api/auth/passkey/list")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_passkey_list_success(test_app, admin_headers):
|
||||
"""Test passkey list returns passkeys"""
|
||||
response = test_app.get("/api/auth/passkey/list", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "passkeys" in data
|
||||
assert isinstance(data["passkeys"], list)
|
||||
|
||||
|
||||
def test_passkey_delete_requires_auth(test_app):
|
||||
"""Test passkey delete requires authentication"""
|
||||
response = test_app.post("/api/auth/passkey/delete", json={"id": "test"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_passkey_delete_success(test_app, admin_headers):
|
||||
"""Test passkey delete removes passkey"""
|
||||
response = test_app.post("/api/auth/passkey/delete", json={"id": "nonexistent"}, headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
|
||||
def test_passkey_clear_requires_auth(test_app):
|
||||
"""Test passkey clear requires authentication"""
|
||||
response = test_app.post("/api/auth/passkey/clear")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_passkey_clear_success(test_app, admin_headers):
|
||||
"""Test passkey clear removes all passkeys"""
|
||||
response = test_app.post("/api/auth/passkey/clear", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
|
||||
def test_passkey_stores_username_and_role(test_app, admin_headers):
|
||||
"""Test passkey registration stores username and role"""
|
||||
# This would require full WebAuthn flow
|
||||
# Verify that the fix for privilege escalation is in place
|
||||
pass
|
||||
|
||||
|
||||
def test_passkey_login_uses_stored_role(test_app):
|
||||
"""Test passkey login uses role from credential, not hardcoded admin"""
|
||||
# This would require full WebAuthn flow
|
||||
# Verify that login doesn't grant admin to all passkey users
|
||||
pass
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Integration tests for security router"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_security_logs_endpoint(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security logs endpoint returns expected format"""
|
||||
# Mock Authelia container logs
|
||||
mock_container = mock_docker_client.containers.get.return_value
|
||||
mock_container.logs.return_value = b'{"level":"error","msg":"Unsuccessful 1FA authentication attempt","time":"2024-01-01T10:00:00Z","username":"testuser","remote_ip":"1.2.3.4"}\n'
|
||||
|
||||
response = test_app.get("/api/security/logs", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "logs" in data
|
||||
assert isinstance(data["logs"], list)
|
||||
|
||||
|
||||
def test_security_logs_with_filters(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security logs with type and IP filters"""
|
||||
mock_container = mock_docker_client.containers.get.return_value
|
||||
mock_container.logs.return_value = b'{"level":"error","msg":"Unsuccessful 1FA authentication attempt","time":"2024-01-01T10:00:00Z","username":"testuser","remote_ip":"1.2.3.4"}\n'
|
||||
|
||||
response = test_app.get("/api/security/logs?type=auth_failure&ip=1.2.3.4", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "logs" in data
|
||||
|
||||
|
||||
def test_security_logs_with_date_range(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security logs with date range filter"""
|
||||
mock_container = mock_docker_client.containers.get.return_value
|
||||
mock_container.logs.return_value = b'{"level":"error","msg":"Unsuccessful 1FA authentication attempt","time":"2024-01-01T10:00:00Z","username":"testuser","remote_ip":"1.2.3.4"}\n'
|
||||
|
||||
response = test_app.get(
|
||||
"/api/security/logs?start_date=2024-01-01&end_date=2024-01-31", headers=admin_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "logs" in data
|
||||
|
||||
|
||||
def test_security_logs_tail_limit(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security logs respects tail and limit parameters"""
|
||||
mock_container = mock_docker_client.containers.get.return_value
|
||||
mock_container.logs.return_value = b'{"level":"error","msg":"Unsuccessful 1FA authentication attempt","time":"2024-01-01T10:00:00Z","username":"testuser","remote_ip":"1.2.3.4"}\n'
|
||||
|
||||
response = test_app.get("/api/security/logs?tail=100&limit=50", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "logs" in data
|
||||
assert len(data["logs"]) <= 50
|
||||
|
||||
|
||||
def test_security_logs_handles_missing_container(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security logs handles missing Authelia container gracefully"""
|
||||
import docker.errors
|
||||
|
||||
mock_docker_client.containers.get.side_effect = docker.errors.NotFound("Container not found")
|
||||
|
||||
response = test_app.get("/api/security/logs", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "logs" in data
|
||||
assert isinstance(data["logs"], list)
|
||||
|
||||
|
||||
def test_security_stats_endpoint(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security stats endpoint returns expected format"""
|
||||
mock_container = mock_docker_client.containers.get.return_value
|
||||
mock_container.logs.return_value = b'{"level":"error","msg":"Unsuccessful 1FA authentication attempt","time":"2024-01-01T10:00:00Z","username":"testuser","remote_ip":"1.2.3.4"}\n'
|
||||
|
||||
response = test_app.get("/api/security/stats", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "failed_logins_24h" in data
|
||||
assert "suspicious_requests" in data
|
||||
assert "unique_ips" in data
|
||||
assert "top_ips" in data
|
||||
assert "timeline" in data
|
||||
assert isinstance(data["top_ips"], list)
|
||||
assert isinstance(data["timeline"], list)
|
||||
|
||||
|
||||
def test_security_stats_timeline_format(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security stats timeline has correct format"""
|
||||
mock_container = mock_docker_client.containers.get.return_value
|
||||
mock_container.logs.return_value = b'{"level":"error","msg":"Unsuccessful 1FA authentication attempt","time":"2024-01-01T10:00:00Z","username":"testuser","remote_ip":"1.2.3.4"}\n'
|
||||
|
||||
response = test_app.get("/api/security/stats", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["timeline"]) == 24 # 24 hours
|
||||
for entry in data["timeline"]:
|
||||
assert "hours_ago" in entry
|
||||
assert "count" in entry
|
||||
|
||||
|
||||
def test_security_stats_top_ips_format(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security stats top IPs have correct format"""
|
||||
mock_container = mock_docker_client.containers.get.return_value
|
||||
mock_container.logs.return_value = b'{"level":"error","msg":"Unsuccessful 1FA authentication attempt","time":"2024-01-01T10:00:00Z","username":"testuser","remote_ip":"1.2.3.4"}\n'
|
||||
|
||||
response = test_app.get("/api/security/stats", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for entry in data["top_ips"]:
|
||||
assert "ip" in entry
|
||||
assert "count" in entry
|
||||
|
||||
|
||||
def test_security_logs_parses_logfmt(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security logs can parse logfmt format"""
|
||||
mock_container = mock_docker_client.containers.get.return_value
|
||||
mock_container.logs.return_value = (
|
||||
b'level=error msg="Unsuccessful 1FA authentication attempt" time=2024-01-01T10:00:00Z username=testuser remote_ip=1.2.3.4\n'
|
||||
)
|
||||
|
||||
response = test_app.get("/api/security/logs", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "logs" in data
|
||||
|
||||
|
||||
def test_security_logs_filters_suspicious_paths(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security logs identifies suspicious paths"""
|
||||
mock_container = mock_docker_client.containers.get.return_value
|
||||
# Simulate Caddy log with suspicious path (though Caddy logs are disabled in current implementation)
|
||||
mock_container.logs.return_value = b'{"level":"error","msg":"Unsuccessful 1FA authentication attempt","time":"2024-01-01T10:00:00Z","username":"testuser","remote_ip":"1.2.3.4"}\n'
|
||||
|
||||
response = test_app.get("/api/security/logs", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "logs" in data
|
||||
|
||||
|
||||
def test_security_stats_handles_errors(test_app, admin_headers, mock_docker_client):
|
||||
"""Test security stats handles Docker errors gracefully"""
|
||||
mock_docker_client.containers.get.side_effect = Exception("Docker error")
|
||||
|
||||
response = test_app.get("/api/security/stats", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "failed_logins_24h" in data
|
||||
assert data["failed_logins_24h"] == 0
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Integration tests for system router"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_system_stats_endpoint(test_app, admin_headers):
|
||||
"""Test system stats endpoint returns expected format"""
|
||||
response = test_app.get("/api/system/stats", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "cpu_percent" in data
|
||||
assert "cpu_count" in data
|
||||
assert "load_avg" in data
|
||||
assert "memory" in data
|
||||
assert "disk" in data
|
||||
assert "uptime" in data
|
||||
assert "platform" in data
|
||||
assert "volumes" in data
|
||||
|
||||
|
||||
def test_audit_log_endpoint(test_app, admin_headers, temp_volume_root):
|
||||
"""Test audit log endpoint returns expected format"""
|
||||
import os
|
||||
|
||||
audit_path = os.path.join(temp_volume_root, "docker/nas-dashboard/audit.log")
|
||||
os.makedirs(os.path.dirname(audit_path), exist_ok=True)
|
||||
with open(audit_path, "w") as f:
|
||||
f.write("2024-01-01T10:00:00 1.2.3.4 admin GET /api/docker/containers 200 50ms\n")
|
||||
|
||||
response = test_app.get("/api/system/audit-log", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "entries" in data
|
||||
assert isinstance(data["entries"], list)
|
||||
|
||||
|
||||
def test_audit_log_classifies_failed_auth(test_app, admin_headers, temp_volume_root):
|
||||
"""Test audit log classifies failed auth as high severity"""
|
||||
import os
|
||||
|
||||
audit_path = os.path.join(temp_volume_root, "docker/nas-dashboard/audit.log")
|
||||
os.makedirs(os.path.dirname(audit_path), exist_ok=True)
|
||||
with open(audit_path, "w") as f:
|
||||
f.write("2024-01-01T10:00:00 1.2.3.4 - POST /api/auth/login 401 100ms\n")
|
||||
|
||||
response = test_app.get("/api/system/audit-log", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
if data["entries"]:
|
||||
assert data["entries"][0]["level"] == "high"
|
||||
|
||||
|
||||
def test_send_notification_missing_message(test_app, admin_headers):
|
||||
"""Test send notification with missing message"""
|
||||
response = test_app.post("/api/system/notify", json={}, headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is False
|
||||
assert "error" in data
|
||||
|
||||
|
||||
def test_openclaw_token_lan_only(test_app, admin_headers):
|
||||
"""Test OpenClaw token endpoint requires LAN IP"""
|
||||
response = test_app.get("/api/system/openclaw-token", headers=admin_headers)
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Integration tests for terminal router"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_terminal_endpoint_exists(test_app):
|
||||
"""Test terminal WebSocket endpoint exists"""
|
||||
# WebSocket endpoints can't be tested with TestClient easily
|
||||
# This is a placeholder to ensure the module is tested
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_hosts_configuration(test_app):
|
||||
"""Test terminal has configured hosts"""
|
||||
# Verify HOSTS dict is properly configured
|
||||
from routers.terminal import HOSTS
|
||||
|
||||
assert "nas" in HOSTS
|
||||
assert "host" in HOSTS["nas"]
|
||||
assert "user" in HOSTS["nas"]
|
||||
assert "key" in HOSTS["nas"]
|
||||
|
||||
|
||||
def test_terminal_session_limits(test_app):
|
||||
"""Test terminal enforces session limits"""
|
||||
from routers.terminal import MAX_SESSIONS, MAX_SESSIONS_PER_USER
|
||||
|
||||
assert MAX_SESSIONS > 0
|
||||
assert MAX_SESSIONS_PER_USER > 0
|
||||
assert MAX_SESSIONS_PER_USER <= MAX_SESSIONS
|
||||
|
||||
|
||||
def test_terminal_known_hosts_required(test_app):
|
||||
"""Test terminal requires known_hosts file"""
|
||||
from routers.terminal import _known_hosts_available
|
||||
|
||||
# In production, this should be True
|
||||
# In test environment, it may be False
|
||||
assert isinstance(_known_hosts_available, bool)
|
||||
|
||||
|
||||
def test_terminal_build_host_command(test_app):
|
||||
"""Test terminal builds correct SSH commands"""
|
||||
from routers.terminal import build_host_command
|
||||
|
||||
# Test simple host
|
||||
config = {"command": "bash"}
|
||||
result = build_host_command(config)
|
||||
assert result == "bash"
|
||||
|
||||
# Test host without command
|
||||
config = {"host": "example.com"}
|
||||
result = build_host_command(config)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_terminal_persistent_session_command(test_app):
|
||||
"""Test terminal builds correct persistent session commands"""
|
||||
from routers.terminal import build_host_command
|
||||
|
||||
config = {
|
||||
"persistent_session": {
|
||||
"container": "test-container",
|
||||
"session_name": "test-session"
|
||||
}
|
||||
}
|
||||
result = build_host_command(config)
|
||||
assert result is not None
|
||||
assert "tmux" in result
|
||||
assert "test-container" in result
|
||||
assert "test-session" in result
|
||||
|
||||
|
||||
def test_terminal_session_reservation(test_app):
|
||||
"""Test terminal session reservation logic"""
|
||||
# This would require async testing
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_session_release(test_app):
|
||||
"""Test terminal session release logic"""
|
||||
# This would require async testing
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_handles_resize_protocol(test_app):
|
||||
"""Test terminal handles terminal resize protocol"""
|
||||
# Resize messages start with 0x01 byte
|
||||
# Followed by 2 bytes for cols and 2 bytes for rows
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_handles_keepalive(test_app):
|
||||
"""Test terminal handles keepalive ping/pong"""
|
||||
# Should respond to __ping__ with __pong__
|
||||
pass
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Integration tests for TOTP 2FA operations.
|
||||
"""
|
||||
|
||||
import pyotp
|
||||
|
||||
|
||||
class TestTOTPSetup:
|
||||
"""Test TOTP 2FA setup endpoints."""
|
||||
|
||||
def test_generate_2fa_secret(self, test_app, admin_headers):
|
||||
"""Test generating a new 2FA secret."""
|
||||
response = test_app.post("/api/auth/setup-2fa/generate", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "secret" in data
|
||||
assert "uri" in data
|
||||
assert len(data["secret"]) == 32 # Base32 secret length
|
||||
assert "otpauth://totp/" in data["uri"]
|
||||
assert "NAS" in data["uri"] and "Dashboard" in data["uri"]
|
||||
|
||||
def test_generate_2fa_without_auth(self, test_app):
|
||||
"""Test generating 2FA secret without authentication."""
|
||||
response = test_app.post("/api/auth/setup-2fa/generate")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_verify_2fa_setup_success(self, test_app, admin_headers):
|
||||
"""Test verifying and enabling 2FA with valid code."""
|
||||
# First generate a secret
|
||||
gen_response = test_app.post("/api/auth/setup-2fa/generate", headers=admin_headers)
|
||||
secret = gen_response.json()["secret"]
|
||||
|
||||
# Generate a valid TOTP code
|
||||
totp = pyotp.TOTP(secret)
|
||||
valid_code = totp.now()
|
||||
|
||||
# Verify the code
|
||||
response = test_app.post(
|
||||
"/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": secret, "code": valid_code}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
assert "enabled" in data["message"].lower()
|
||||
|
||||
def test_verify_2fa_setup_invalid_code(self, test_app, admin_headers):
|
||||
"""Test verifying 2FA with invalid code."""
|
||||
response = test_app.post(
|
||||
"/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": "JBSWY3DPEHPK3PXP", "code": "000000"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "invalid" in data["detail"].lower()
|
||||
|
||||
def test_verify_2fa_without_auth(self, test_app):
|
||||
"""Test verifying 2FA without authentication."""
|
||||
response = test_app.post("/api/auth/setup-2fa/verify", json={"secret": "JBSWY3DPEHPK3PXP", "code": "123456"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_disable_2fa(self, test_app, admin_headers):
|
||||
"""Test disabling 2FA."""
|
||||
response = test_app.post("/api/auth/setup-2fa/disable", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
assert "disabled" in data["message"].lower()
|
||||
|
||||
def test_disable_2fa_without_auth(self, test_app):
|
||||
"""Test disabling 2FA without authentication."""
|
||||
response = test_app.post("/api/auth/setup-2fa/disable")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestTOTPWorkflow:
|
||||
"""Test complete 2FA setup workflow."""
|
||||
|
||||
def test_complete_2fa_workflow(self, test_app, admin_headers, mock_config, temp_auth_file):
|
||||
"""Test the complete 2FA setup and disable workflow."""
|
||||
# Step 1: Generate secret
|
||||
gen_response = test_app.post("/api/auth/setup-2fa/generate", headers=admin_headers)
|
||||
assert gen_response.status_code == 200
|
||||
secret = gen_response.json()["secret"]
|
||||
|
||||
# Step 2: Verify and enable with valid code
|
||||
totp = pyotp.TOTP(secret)
|
||||
valid_code = totp.now()
|
||||
verify_response = test_app.post(
|
||||
"/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": secret, "code": valid_code}
|
||||
)
|
||||
assert verify_response.status_code == 200
|
||||
|
||||
# Step 3: Verify 2FA is enabled by checking auth_service
|
||||
from auth_service import load_totp_secret
|
||||
|
||||
saved_secret = load_totp_secret()
|
||||
assert saved_secret == secret
|
||||
|
||||
# Step 4: Disable 2FA
|
||||
disable_response = test_app.post("/api/auth/setup-2fa/disable", headers=admin_headers)
|
||||
assert disable_response.status_code == 200
|
||||
|
||||
# Step 5: Verify 2FA is disabled
|
||||
saved_secret = load_totp_secret()
|
||||
assert saved_secret == ""
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Integration tests for WebSocket security (terminal and OPC)"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
def test_terminal_ws_requires_authentication(test_app):
|
||||
"""Test terminal WebSocket requires authentication"""
|
||||
# Try to connect without auth cookie
|
||||
with test_app.websocket_connect("/api/terminal/ws?host=nas") as websocket:
|
||||
# Should be rejected
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_rejects_invalid_token(test_app):
|
||||
"""Test terminal WebSocket rejects invalid tokens"""
|
||||
# Try to connect with invalid token
|
||||
with pytest.raises(Exception):
|
||||
with test_app.websocket_connect(
|
||||
"/api/terminal/ws?host=nas", cookies={"nas_access_token": "invalid-token"}
|
||||
) as websocket:
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_rejects_expired_token(test_app, expired_access_token):
|
||||
"""Test terminal WebSocket rejects expired tokens"""
|
||||
with pytest.raises(Exception):
|
||||
with test_app.websocket_connect(
|
||||
"/api/terminal/ws?host=nas", cookies={"nas_access_token": expired_access_token}
|
||||
) as websocket:
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_requires_page_access(test_app, valid_access_token):
|
||||
"""Test terminal WebSocket requires terminal page access"""
|
||||
# This test would need a token for a user without terminal access
|
||||
# For now, we verify the endpoint exists and has auth
|
||||
with pytest.raises(Exception):
|
||||
with test_app.websocket_connect(
|
||||
"/api/terminal/ws?host=nas", cookies={"nas_access_token": "invalid"}
|
||||
) as websocket:
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_rejects_unknown_host(test_app, valid_access_token):
|
||||
"""Test terminal WebSocket rejects unknown hosts"""
|
||||
with pytest.raises(Exception):
|
||||
with test_app.websocket_connect(
|
||||
"/api/terminal/ws?host=unknown", cookies={"nas_access_token": valid_access_token}
|
||||
) as websocket:
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_enforces_session_limit(test_app, valid_access_token):
|
||||
"""Test terminal WebSocket enforces max sessions per user"""
|
||||
# This would require actually establishing multiple connections
|
||||
# For now, we verify the endpoint exists
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_validates_known_hosts(test_app, valid_access_token):
|
||||
"""Test terminal WebSocket validates SSH known_hosts"""
|
||||
# The endpoint should fail if known_hosts is not available
|
||||
# This is tested by the _known_hosts_available check
|
||||
pass
|
||||
|
||||
|
||||
def test_opc_ws_requires_authentication(test_app):
|
||||
"""Test OPC WebSocket requires authentication"""
|
||||
# OPC WebSocket should also require authentication
|
||||
with pytest.raises(Exception):
|
||||
with test_app.websocket_connect("/api/opc/ws") as websocket:
|
||||
pass
|
||||
|
||||
|
||||
def test_opc_ws_accepts_ping(test_app, valid_access_token):
|
||||
"""Test OPC WebSocket responds to ping"""
|
||||
# This would require mocking the WebSocket connection
|
||||
pass
|
||||
|
||||
|
||||
def test_opc_ws_broadcasts_task_updates(test_app, admin_headers):
|
||||
"""Test OPC WebSocket broadcasts task updates"""
|
||||
# This would require establishing a WebSocket connection and creating a task
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_handles_resize_messages(test_app, valid_access_token):
|
||||
"""Test terminal WebSocket handles terminal resize messages"""
|
||||
# Terminal resize messages start with 0x01 byte followed by cols/rows
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_handles_ping_pong(test_app, valid_access_token):
|
||||
"""Test terminal WebSocket handles ping/pong keepalive"""
|
||||
# Terminal should respond to __ping__ with __pong__
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_closes_on_ssh_error(test_app, valid_access_token):
|
||||
"""Test terminal WebSocket closes gracefully on SSH errors"""
|
||||
# Should close connection if SSH connection fails
|
||||
pass
|
||||
|
||||
|
||||
def test_terminal_ws_releases_session_on_close(test_app, valid_access_token):
|
||||
"""Test terminal WebSocket releases session slot on close"""
|
||||
# Should decrement active session count when connection closes
|
||||
pass
|
||||
|
||||
|
||||
def test_opc_ws_filters_messages_by_authorization(test_app, admin_headers):
|
||||
"""Test OPC WebSocket only sends messages for authorized tasks"""
|
||||
# Users should only receive updates for tasks they have access to
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Basic diagnostic tests to validate test environment.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def test_python_version():
|
||||
"""Verify Python version."""
|
||||
assert sys.version_info >= (3, 10), f"Python version: {sys.version}"
|
||||
|
||||
|
||||
def test_imports():
|
||||
"""Verify all required modules can be imported."""
|
||||
try:
|
||||
import asyncssh
|
||||
import docker
|
||||
import fastapi
|
||||
import httpx
|
||||
import jwt
|
||||
import passlib
|
||||
import pyotp
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import pytest_cov
|
||||
import pytest_mock
|
||||
import uvicorn
|
||||
|
||||
assert True
|
||||
except ImportError as e:
|
||||
pytest.fail(f"Import failed: {e}")
|
||||
|
||||
|
||||
def test_basic_math():
|
||||
"""Sanity check that tests can run."""
|
||||
assert 1 + 1 == 2
|
||||
@@ -0,0 +1 @@
|
||||
# Unit tests
|
||||
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Unit tests for auth.py - JWT, TOTP, password hashing, encryption.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pyotp
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from auth_service import (
|
||||
_decrypt,
|
||||
_encrypt,
|
||||
clear_passkey_credentials,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
get_password_hash,
|
||||
increment_token_version,
|
||||
load_passkey_credentials,
|
||||
load_password_hash,
|
||||
load_totp_secret,
|
||||
save_passkey_credential,
|
||||
save_password_hash,
|
||||
save_totp_secret,
|
||||
user_from_access_token,
|
||||
verify_password,
|
||||
verify_token_version,
|
||||
verify_totp,
|
||||
)
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
"""Test password hashing and verification."""
|
||||
|
||||
def test_hash_and_verify_password(self):
|
||||
"""Test that password hashing and verification works."""
|
||||
password = "test_password_123"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert hashed != password
|
||||
assert verify_password(password, hashed)
|
||||
|
||||
def test_verify_wrong_password(self):
|
||||
"""Test that wrong password fails verification."""
|
||||
password = "correct_password"
|
||||
wrong_password = "wrong_password"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
assert not verify_password(wrong_password, hashed)
|
||||
|
||||
def test_different_hashes_for_same_password(self):
|
||||
"""Test that same password produces different hashes (salt)."""
|
||||
password = "test_password"
|
||||
hash1 = get_password_hash(password)
|
||||
hash2 = get_password_hash(password)
|
||||
|
||||
assert hash1 != hash2
|
||||
assert verify_password(password, hash1)
|
||||
assert verify_password(password, hash2)
|
||||
|
||||
|
||||
class TestJWTTokens:
|
||||
"""Test JWT token creation and validation."""
|
||||
|
||||
def test_create_access_token(self, mock_config):
|
||||
"""Test access token creation."""
|
||||
data = {"sub": "testuser", "role": "admin"}
|
||||
token = create_access_token(data)
|
||||
|
||||
assert token is not None
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
assert payload["sub"] == "testuser"
|
||||
assert payload["role"] == "admin"
|
||||
assert payload["type"] == "access"
|
||||
assert "exp" in payload
|
||||
|
||||
def test_create_access_token_with_custom_expiry(self, mock_config):
|
||||
"""Test access token with custom expiration."""
|
||||
data = {"sub": "testuser"}
|
||||
expires_delta = timedelta(minutes=60)
|
||||
token = create_access_token(data, expires_delta)
|
||||
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
exp_time = datetime.fromtimestamp(payload["exp"], tz=UTC)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Should expire in approximately 60 minutes
|
||||
time_diff = (exp_time - now).total_seconds()
|
||||
assert 3500 < time_diff < 3700 # ~60 minutes with some tolerance
|
||||
|
||||
def test_create_refresh_token(self, mock_config, temp_auth_file):
|
||||
"""Test refresh token creation."""
|
||||
data = {"sub": "testuser", "role": "member"}
|
||||
token = create_refresh_token(data)
|
||||
|
||||
assert token is not None
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
assert payload["sub"] == "testuser"
|
||||
assert payload["role"] == "member"
|
||||
assert payload["type"] == "refresh"
|
||||
assert "tv" in payload # token version
|
||||
assert "exp" in payload
|
||||
|
||||
def test_user_from_valid_access_token(self, mock_config, temp_rbac_file):
|
||||
"""Test extracting user from valid access token."""
|
||||
token = create_access_token({"sub": "testuser", "role": "admin"})
|
||||
user = user_from_access_token(token)
|
||||
|
||||
assert user.username == "testuser"
|
||||
assert user.role == "admin"
|
||||
assert user.pages == "*"
|
||||
assert user.sidebar_links == "*"
|
||||
|
||||
def test_user_from_expired_token(self, mock_config, temp_rbac_file):
|
||||
"""Test that expired token raises HTTPException."""
|
||||
token = create_access_token({"sub": "testuser", "role": "admin"}, expires_delta=timedelta(seconds=-1))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_user_from_invalid_token(self, mock_config, temp_rbac_file):
|
||||
"""Test that invalid token raises HTTPException."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token("invalid.token.here")
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_user_from_refresh_token_fails(self, mock_config, temp_rbac_file, temp_auth_file):
|
||||
"""Test that refresh token cannot be used as access token."""
|
||||
token = create_refresh_token({"sub": "testuser", "role": "admin"})
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_user_from_token_without_username(self, mock_config, temp_rbac_file):
|
||||
"""Test that token without username fails."""
|
||||
# Create token without 'sub' field
|
||||
token = jwt.encode(
|
||||
{"role": "admin", "type": "access", "exp": datetime.now(UTC) + timedelta(minutes=30)},
|
||||
mock_config.SECRET_KEY,
|
||||
algorithm=mock_config.ALGORITHM,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
class TestEncryption:
|
||||
"""Test encryption and decryption functions."""
|
||||
|
||||
def test_encrypt_decrypt(self, mock_config):
|
||||
"""Test basic encryption and decryption."""
|
||||
plaintext = "my-secret-totp-key"
|
||||
encrypted = _encrypt(plaintext)
|
||||
|
||||
assert encrypted != plaintext
|
||||
assert encrypted != ""
|
||||
|
||||
decrypted = _decrypt(encrypted)
|
||||
assert decrypted == plaintext
|
||||
|
||||
def test_encrypt_empty_string(self, mock_config):
|
||||
"""Test encrypting empty string."""
|
||||
encrypted = _encrypt("")
|
||||
assert encrypted == ""
|
||||
|
||||
decrypted = _decrypt("")
|
||||
assert decrypted == ""
|
||||
|
||||
def test_decrypt_invalid_ciphertext(self, mock_config):
|
||||
"""Test decrypting invalid ciphertext returns original."""
|
||||
invalid = "not-a-valid-encrypted-string"
|
||||
decrypted = _decrypt(invalid)
|
||||
|
||||
# Should fallback to returning the original string
|
||||
assert decrypted == invalid
|
||||
|
||||
def test_encrypt_decrypt_unicode(self, mock_config):
|
||||
"""Test encryption with unicode characters."""
|
||||
plaintext = "测试密钥🔐"
|
||||
encrypted = _encrypt(plaintext)
|
||||
decrypted = _decrypt(encrypted)
|
||||
|
||||
assert decrypted == plaintext
|
||||
|
||||
|
||||
class TestTOTP:
|
||||
"""Test TOTP functionality."""
|
||||
|
||||
def test_save_and_load_totp_secret(self, mock_config, temp_auth_file):
|
||||
"""Test saving and loading TOTP secret."""
|
||||
secret = "JBSWY3DPEHPK3PXP"
|
||||
save_totp_secret(secret)
|
||||
|
||||
loaded = load_totp_secret()
|
||||
assert loaded == secret
|
||||
|
||||
def test_load_totp_secret_from_env(self, mock_config, temp_auth_file, monkeypatch):
|
||||
"""Test loading TOTP secret from environment variable."""
|
||||
# Clear any existing secret in file first
|
||||
save_totp_secret("")
|
||||
|
||||
monkeypatch.setenv("TOTP_SECRET", "ENV_SECRET_KEY")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
# When no secret in file, should return env var
|
||||
loaded = load_totp_secret()
|
||||
assert loaded == "ENV_SECRET_KEY"
|
||||
|
||||
def test_verify_totp_valid_code(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test verifying valid TOTP code."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
totp = pyotp.TOTP(sample_totp_secret)
|
||||
valid_code = totp.now()
|
||||
|
||||
assert verify_totp(valid_code)
|
||||
|
||||
def test_verify_totp_invalid_code(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test verifying invalid TOTP code."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
assert not verify_totp("000000")
|
||||
|
||||
def test_verify_totp_no_secret_enabled(self, mock_config, temp_auth_file, monkeypatch):
|
||||
"""Test that TOTP verification passes when 2FA not enabled."""
|
||||
# Clear any existing secret from previous tests
|
||||
save_totp_secret("")
|
||||
# Also clear environment variable fallback
|
||||
monkeypatch.setenv("TOTP_SECRET", "")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
# No secret saved, should return True (2FA disabled)
|
||||
assert verify_totp("any_code")
|
||||
|
||||
def test_verify_totp_replay_protection(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test that same TOTP code cannot be used twice."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
# Ensure auth file is properly initialized with last_totp_ts
|
||||
import json
|
||||
|
||||
with open(temp_auth_file) as f:
|
||||
data = json.load(f)
|
||||
data["last_totp_ts"] = 0
|
||||
with open(temp_auth_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
totp = pyotp.TOTP(sample_totp_secret)
|
||||
valid_code = totp.now()
|
||||
|
||||
# First use should succeed
|
||||
result = verify_totp(valid_code)
|
||||
assert result, f"First TOTP verification failed for code {valid_code}"
|
||||
|
||||
# Second use of same code should fail (replay protection)
|
||||
assert not verify_totp(valid_code)
|
||||
|
||||
|
||||
class TestPasswordPersistence:
|
||||
"""Test password hash persistence."""
|
||||
|
||||
def test_save_and_load_password_hash(self, mock_config, temp_auth_file):
|
||||
"""Test saving and loading password hash."""
|
||||
hashed = get_password_hash("new_password")
|
||||
save_password_hash(hashed)
|
||||
|
||||
loaded = load_password_hash()
|
||||
assert loaded == hashed
|
||||
|
||||
def test_load_password_hash_from_env(self, mock_config, temp_auth_file):
|
||||
"""Test loading password hash from environment variable."""
|
||||
# Clear any hash in the file first
|
||||
import json
|
||||
|
||||
with open(temp_auth_file) as f:
|
||||
data = json.load(f)
|
||||
data["password_hash"] = ""
|
||||
with open(temp_auth_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# Reload auth_service to clear any cached data
|
||||
import importlib
|
||||
|
||||
import auth_service
|
||||
|
||||
importlib.reload(auth_service)
|
||||
|
||||
# When no hash in file, should return env var
|
||||
loaded = auth_service.load_password_hash()
|
||||
assert loaded == mock_config.ADMIN_PASSWORD_HASH
|
||||
|
||||
|
||||
class TestPasskeyCredentials:
|
||||
"""Test passkey credential management."""
|
||||
|
||||
def test_save_and_load_passkey_credentials(self, mock_config, temp_auth_file):
|
||||
"""Test saving and loading passkey credentials."""
|
||||
cred1 = {"id": "cred1", "public_key": "key1"}
|
||||
cred2 = {"id": "cred2", "public_key": "key2"}
|
||||
|
||||
save_passkey_credential(cred1)
|
||||
save_passkey_credential(cred2)
|
||||
|
||||
creds = load_passkey_credentials()
|
||||
assert len(creds) == 2
|
||||
assert cred1 in creds
|
||||
assert cred2 in creds
|
||||
|
||||
def test_clear_passkey_credentials(self, mock_config, temp_auth_file):
|
||||
"""Test clearing all passkey credentials."""
|
||||
save_passkey_credential({"id": "cred1"})
|
||||
save_passkey_credential({"id": "cred2"})
|
||||
|
||||
clear_passkey_credentials()
|
||||
|
||||
creds = load_passkey_credentials()
|
||||
assert len(creds) == 0
|
||||
|
||||
|
||||
class TestTokenVersion:
|
||||
"""Test token version management for refresh token rotation."""
|
||||
|
||||
def test_increment_token_version(self, mock_config, temp_auth_file):
|
||||
"""Test incrementing token version."""
|
||||
v1 = increment_token_version()
|
||||
assert v1 == 1
|
||||
|
||||
v2 = increment_token_version()
|
||||
assert v2 == 2
|
||||
|
||||
v3 = increment_token_version()
|
||||
assert v3 == 3
|
||||
|
||||
def test_verify_token_version(self, mock_config, temp_auth_file):
|
||||
"""Test verifying token version."""
|
||||
v1 = increment_token_version()
|
||||
assert verify_token_version(v1)
|
||||
|
||||
v2 = increment_token_version()
|
||||
assert verify_token_version(v2)
|
||||
assert not verify_token_version(v1) # Old version should fail
|
||||
|
||||
def test_token_version_in_refresh_token(self, mock_config, temp_auth_file):
|
||||
"""Test that refresh token includes current token version."""
|
||||
current_version = increment_token_version()
|
||||
token = create_refresh_token({"sub": "testuser"})
|
||||
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
assert payload["tv"] == current_version
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Unit tests for config.py - IP parsing, environment validation.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from config import (
|
||||
_ip_matches_ranges,
|
||||
_parse_ip,
|
||||
get_client_ip,
|
||||
get_forwarded_client_ip,
|
||||
is_lan_ip,
|
||||
is_tailscale_ip,
|
||||
is_trusted_proxy,
|
||||
)
|
||||
|
||||
|
||||
class TestParseIP:
|
||||
"""Test IP address parsing."""
|
||||
|
||||
def test_parse_valid_ipv4(self):
|
||||
"""Test parsing valid IPv4 address."""
|
||||
ip = _parse_ip("192.168.1.1")
|
||||
assert ip is not None
|
||||
assert str(ip) == "192.168.1.1"
|
||||
|
||||
def test_parse_valid_ipv6(self):
|
||||
"""Test parsing valid IPv6 address."""
|
||||
ip = _parse_ip("2001:db8::1")
|
||||
assert ip is not None
|
||||
assert str(ip) == "2001:db8::1"
|
||||
|
||||
def test_parse_localhost_ipv4(self):
|
||||
"""Test parsing localhost IPv4."""
|
||||
ip = _parse_ip("127.0.0.1")
|
||||
assert ip is not None
|
||||
assert str(ip) == "127.0.0.1"
|
||||
|
||||
def test_parse_localhost_ipv6(self):
|
||||
"""Test parsing localhost IPv6."""
|
||||
ip = _parse_ip("::1")
|
||||
assert ip is not None
|
||||
assert str(ip) == "::1"
|
||||
|
||||
def test_parse_invalid_ip(self):
|
||||
"""Test parsing invalid IP returns None."""
|
||||
assert _parse_ip("not.an.ip.address") is None
|
||||
assert _parse_ip("999.999.999.999") is None
|
||||
assert _parse_ip("") is None
|
||||
assert _parse_ip(" ") is None
|
||||
|
||||
def test_parse_ip_with_whitespace(self):
|
||||
"""Test parsing IP with surrounding whitespace."""
|
||||
ip = _parse_ip(" 192.168.1.1 ")
|
||||
assert ip is not None
|
||||
assert str(ip) == "192.168.1.1"
|
||||
|
||||
|
||||
class TestIPMatchesRanges:
|
||||
"""Test IP range matching."""
|
||||
|
||||
def test_match_single_ip(self):
|
||||
"""Test matching against single IP."""
|
||||
assert _ip_matches_ranges("192.168.1.1", ["192.168.1.1"])
|
||||
assert not _ip_matches_ranges("192.168.1.2", ["192.168.1.1"])
|
||||
|
||||
def test_match_cidr_range(self):
|
||||
"""Test matching against CIDR range."""
|
||||
ranges = ["192.168.1.0/24"]
|
||||
assert _ip_matches_ranges("192.168.1.1", ranges)
|
||||
assert _ip_matches_ranges("192.168.1.100", ranges)
|
||||
assert _ip_matches_ranges("192.168.1.254", ranges)
|
||||
assert not _ip_matches_ranges("192.168.2.1", ranges)
|
||||
|
||||
def test_match_multiple_ranges(self):
|
||||
"""Test matching against multiple ranges."""
|
||||
ranges = ["192.168.1.0/24", "10.0.0.0/8", "172.16.0.1"]
|
||||
assert _ip_matches_ranges("192.168.1.50", ranges)
|
||||
assert _ip_matches_ranges("10.5.10.20", ranges)
|
||||
assert _ip_matches_ranges("172.16.0.1", ranges)
|
||||
assert not _ip_matches_ranges("8.8.8.8", ranges)
|
||||
|
||||
def test_match_ipv6_range(self):
|
||||
"""Test matching IPv6 against range."""
|
||||
ranges = ["2001:db8::/32"]
|
||||
assert _ip_matches_ranges("2001:db8::1", ranges)
|
||||
assert _ip_matches_ranges("2001:db8:1234::5678", ranges)
|
||||
assert not _ip_matches_ranges("2001:db9::1", ranges)
|
||||
|
||||
def test_match_invalid_ip(self):
|
||||
"""Test that invalid IP returns False."""
|
||||
assert not _ip_matches_ranges("invalid", ["192.168.1.0/24"])
|
||||
assert not _ip_matches_ranges("", ["192.168.1.0/24"])
|
||||
|
||||
def test_match_empty_ranges(self):
|
||||
"""Test matching against empty ranges."""
|
||||
assert not _ip_matches_ranges("192.168.1.1", [])
|
||||
|
||||
def test_match_with_whitespace_in_ranges(self):
|
||||
"""Test matching with whitespace in range strings."""
|
||||
ranges = [" 192.168.1.0/24 ", " 10.0.0.1 "]
|
||||
assert _ip_matches_ranges("192.168.1.50", ranges)
|
||||
assert _ip_matches_ranges("10.0.0.1", ranges)
|
||||
|
||||
def test_match_with_empty_string_in_ranges(self):
|
||||
"""Test that empty strings in ranges are skipped."""
|
||||
ranges = ["192.168.1.0/24", "", " ", "10.0.0.1"]
|
||||
assert _ip_matches_ranges("192.168.1.50", ranges)
|
||||
assert _ip_matches_ranges("10.0.0.1", ranges)
|
||||
|
||||
def test_match_with_invalid_range(self):
|
||||
"""Test that invalid ranges are skipped."""
|
||||
ranges = ["192.168.1.0/24", "invalid-range", "10.0.0.1"]
|
||||
assert _ip_matches_ranges("192.168.1.50", ranges)
|
||||
assert _ip_matches_ranges("10.0.0.1", ranges)
|
||||
assert not _ip_matches_ranges("8.8.8.8", ranges)
|
||||
|
||||
|
||||
class TestIsLanIP:
|
||||
"""Test LAN IP detection."""
|
||||
|
||||
def test_is_lan_ip_default_range(self, mock_config):
|
||||
"""Test LAN IP detection with default range."""
|
||||
# Default is 192.168.31.0/24
|
||||
assert is_lan_ip("192.168.31.1")
|
||||
assert is_lan_ip("192.168.31.100")
|
||||
assert is_lan_ip("192.168.31.254")
|
||||
assert not is_lan_ip("192.168.30.1")
|
||||
assert not is_lan_ip("192.168.32.1")
|
||||
|
||||
def test_is_lan_ip_not_tailscale(self, mock_config):
|
||||
"""Test that Tailscale IPs are not considered LAN."""
|
||||
assert not is_lan_ip("100.64.0.1")
|
||||
assert not is_lan_ip("100.78.131.124")
|
||||
|
||||
|
||||
class TestIsTailscaleIP:
|
||||
"""Test Tailscale IP detection."""
|
||||
|
||||
def test_is_tailscale_ip_valid(self):
|
||||
"""Test Tailscale IP detection."""
|
||||
assert is_tailscale_ip("100.64.0.1")
|
||||
assert is_tailscale_ip("100.78.131.124")
|
||||
assert is_tailscale_ip("100.100.100.100")
|
||||
assert is_tailscale_ip("100.127.255.254")
|
||||
|
||||
def test_is_tailscale_ip_invalid(self):
|
||||
"""Test non-Tailscale IPs."""
|
||||
assert not is_tailscale_ip("100.63.255.255") # Just below range
|
||||
assert not is_tailscale_ip("100.128.0.0") # Just above range
|
||||
assert not is_tailscale_ip("192.168.1.1")
|
||||
assert not is_tailscale_ip("10.0.0.1")
|
||||
|
||||
def test_is_tailscale_ip_boundary(self):
|
||||
"""Test Tailscale IP range boundaries."""
|
||||
# 100.64.0.0/10 means 100.64.0.0 to 100.127.255.255
|
||||
assert is_tailscale_ip("100.64.0.0") # Start of range
|
||||
assert is_tailscale_ip("100.127.255.255") # End of range
|
||||
|
||||
|
||||
class TestIsTrustedProxy:
|
||||
"""Test trusted proxy detection."""
|
||||
|
||||
def test_is_trusted_proxy_default(self, mock_config):
|
||||
"""Test trusted proxy detection with default config."""
|
||||
# Default: 127.0.0.1,::1,172.21.0.1
|
||||
assert is_trusted_proxy("127.0.0.1")
|
||||
assert is_trusted_proxy("::1")
|
||||
assert is_trusted_proxy("172.21.0.1")
|
||||
assert not is_trusted_proxy("192.168.1.1")
|
||||
|
||||
|
||||
class TestGetForwardedClientIP:
|
||||
"""Test forwarded client IP extraction."""
|
||||
|
||||
def test_get_forwarded_ip_from_trusted_proxy(self, mock_config):
|
||||
"""Test extracting forwarded IP from trusted proxy."""
|
||||
request = Mock()
|
||||
request.client = Mock()
|
||||
request.client.host = "127.0.0.1" # Trusted proxy
|
||||
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||
|
||||
ip = get_forwarded_client_ip(request)
|
||||
assert ip == "203.0.113.1"
|
||||
|
||||
def test_get_forwarded_ip_from_untrusted_proxy(self, mock_config):
|
||||
"""Test that untrusted proxy returns None."""
|
||||
request = Mock()
|
||||
request.client = Mock()
|
||||
request.client.host = "8.8.8.8" # Not trusted
|
||||
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||
|
||||
ip = get_forwarded_client_ip(request)
|
||||
assert ip is None
|
||||
|
||||
def test_get_forwarded_ip_multiple_hops(self, mock_config):
|
||||
"""Test extracting first IP from multiple forwarded IPs."""
|
||||
request = Mock()
|
||||
request.client = Mock()
|
||||
request.client.host = "127.0.0.1"
|
||||
request.headers = {"x-forwarded-for": "203.0.113.1, 198.51.100.1, 192.0.2.1"}
|
||||
|
||||
ip = get_forwarded_client_ip(request)
|
||||
assert ip == "203.0.113.1"
|
||||
|
||||
def test_get_forwarded_ip_no_header(self, mock_config):
|
||||
"""Test when x-forwarded-for header is missing."""
|
||||
request = Mock()
|
||||
request.client = Mock()
|
||||
request.client.host = "127.0.0.1"
|
||||
request.headers = {}
|
||||
|
||||
ip = get_forwarded_client_ip(request)
|
||||
assert ip is None
|
||||
|
||||
def test_get_forwarded_ip_empty_header(self, mock_config):
|
||||
"""Test when x-forwarded-for header is empty."""
|
||||
request = Mock()
|
||||
request.client = Mock()
|
||||
request.client.host = "127.0.0.1"
|
||||
request.headers = {"x-forwarded-for": ""}
|
||||
|
||||
ip = get_forwarded_client_ip(request)
|
||||
assert ip is None
|
||||
|
||||
def test_get_forwarded_ip_no_client(self, mock_config):
|
||||
"""Test when request has no client."""
|
||||
request = Mock()
|
||||
request.client = None
|
||||
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||
|
||||
ip = get_forwarded_client_ip(request)
|
||||
assert ip is None
|
||||
|
||||
|
||||
class TestGetClientIP:
|
||||
"""Test client IP extraction."""
|
||||
|
||||
def test_get_client_ip_direct(self, mock_config):
|
||||
"""Test getting client IP from direct connection."""
|
||||
request = Mock()
|
||||
request.client = Mock()
|
||||
request.client.host = "192.168.1.100"
|
||||
request.headers = {}
|
||||
|
||||
ip = get_client_ip(request)
|
||||
assert ip == "192.168.1.100"
|
||||
|
||||
def test_get_client_ip_via_trusted_proxy(self, mock_config):
|
||||
"""Test getting client IP via trusted proxy."""
|
||||
request = Mock()
|
||||
request.client = Mock()
|
||||
request.client.host = "127.0.0.1"
|
||||
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||
|
||||
ip = get_client_ip(request)
|
||||
assert ip == "203.0.113.1"
|
||||
|
||||
def test_get_client_ip_via_untrusted_proxy(self, mock_config):
|
||||
"""Test getting client IP via untrusted proxy falls back to direct."""
|
||||
request = Mock()
|
||||
request.client = Mock()
|
||||
request.client.host = "8.8.8.8"
|
||||
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||
|
||||
ip = get_client_ip(request)
|
||||
assert ip == "8.8.8.8"
|
||||
|
||||
def test_get_client_ip_no_client(self, mock_config):
|
||||
"""Test getting client IP when request has no client."""
|
||||
request = Mock()
|
||||
request.client = None
|
||||
request.headers = {}
|
||||
|
||||
ip = get_client_ip(request)
|
||||
assert ip == ""
|
||||
|
||||
|
||||
class TestConfigValidation:
|
||||
"""Test configuration validation."""
|
||||
|
||||
def test_secret_key_validation(self, monkeypatch):
|
||||
"""Test that short SECRET_KEY raises error."""
|
||||
monkeypatch.setenv("SECRET_KEY", "short")
|
||||
|
||||
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
def test_secret_key_empty(self, monkeypatch):
|
||||
"""Test that empty SECRET_KEY raises error."""
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Unit tests for rbac.py - Role-based access control.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from rbac import (
|
||||
DEFAULT_RBAC,
|
||||
ROLE_GROUP_MAP,
|
||||
get_pages,
|
||||
get_sidebar_links,
|
||||
get_sidebar_order,
|
||||
load_rbac,
|
||||
resolve_role,
|
||||
save_rbac,
|
||||
update_rbac,
|
||||
)
|
||||
|
||||
|
||||
class TestLoadRBAC:
|
||||
"""Test loading RBAC configuration."""
|
||||
|
||||
def test_load_rbac_from_file(self, mock_config, temp_rbac_file):
|
||||
"""Test loading RBAC from existing file."""
|
||||
rbac = load_rbac()
|
||||
|
||||
assert "role_defaults" in rbac
|
||||
assert "user_overrides" in rbac
|
||||
assert "admin" in rbac["role_defaults"]
|
||||
assert "member" in rbac["role_defaults"]
|
||||
assert "viewer" in rbac["role_defaults"]
|
||||
|
||||
def test_load_rbac_missing_file(self, mock_config, temp_volume_root):
|
||||
"""Test loading RBAC when file doesn't exist returns defaults."""
|
||||
# Don't create rbac file
|
||||
rbac = load_rbac()
|
||||
|
||||
assert rbac == DEFAULT_RBAC
|
||||
|
||||
def test_load_rbac_corrupted_file(self, mock_config, temp_volume_root):
|
||||
"""Test loading RBAC with corrupted file returns defaults."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
os.makedirs(os.path.dirname(rbac_file), exist_ok=True)
|
||||
|
||||
# Write invalid JSON
|
||||
with open(rbac_file, "w") as f:
|
||||
f.write("invalid json {{{")
|
||||
|
||||
rbac = load_rbac()
|
||||
assert rbac == DEFAULT_RBAC
|
||||
|
||||
|
||||
class TestSaveRBAC:
|
||||
"""Test saving RBAC configuration."""
|
||||
|
||||
def test_save_rbac(self, mock_config, temp_volume_root):
|
||||
"""Test saving RBAC configuration."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
test_data = {
|
||||
"role_defaults": {"admin": {"pages": "*"}},
|
||||
"user_overrides": {"testuser": {"pages": ["dashboard"]}},
|
||||
}
|
||||
|
||||
save_rbac(test_data)
|
||||
|
||||
assert os.path.exists(rbac_file)
|
||||
with open(rbac_file) as f:
|
||||
loaded = json.load(f)
|
||||
assert loaded == test_data
|
||||
|
||||
def test_save_rbac_creates_directory(self, mock_config, temp_volume_root):
|
||||
"""Test that save_rbac creates directory if it doesn't exist."""
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
|
||||
# Remove directory
|
||||
import shutil
|
||||
|
||||
if os.path.exists(os.path.dirname(rbac_file)):
|
||||
shutil.rmtree(os.path.dirname(rbac_file))
|
||||
|
||||
test_data = {"role_defaults": {}, "user_overrides": {}}
|
||||
save_rbac(test_data)
|
||||
|
||||
assert os.path.exists(rbac_file)
|
||||
|
||||
|
||||
class TestUpdateRBAC:
|
||||
"""Test atomic RBAC updates."""
|
||||
|
||||
def test_update_rbac_with_mutator(self, mock_config, temp_rbac_file):
|
||||
"""Test updating RBAC with mutator function."""
|
||||
|
||||
def add_user_override(data):
|
||||
data["user_overrides"]["newuser"] = {"pages": ["dashboard", "docker"]}
|
||||
return "success"
|
||||
|
||||
result = update_rbac(add_user_override)
|
||||
|
||||
assert result == "success"
|
||||
|
||||
# Verify changes persisted
|
||||
rbac = load_rbac()
|
||||
assert "newuser" in rbac["user_overrides"]
|
||||
assert rbac["user_overrides"]["newuser"]["pages"] == ["dashboard", "docker"]
|
||||
|
||||
def test_update_rbac_thread_safe(self, mock_config, temp_rbac_file):
|
||||
"""Test that update_rbac is thread-safe."""
|
||||
import threading
|
||||
|
||||
results = []
|
||||
|
||||
def increment_counter(data):
|
||||
current = data.get("counter", 0)
|
||||
data["counter"] = current + 1
|
||||
return data["counter"]
|
||||
|
||||
# Run multiple updates concurrently
|
||||
threads = []
|
||||
for _ in range(10):
|
||||
t = threading.Thread(target=lambda: results.append(update_rbac(increment_counter)))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Final counter should be 10
|
||||
rbac = load_rbac()
|
||||
assert rbac.get("counter") == 10
|
||||
|
||||
|
||||
class TestResolveRole:
|
||||
"""Test role resolution from groups."""
|
||||
|
||||
def test_resolve_role_admin(self):
|
||||
"""Test resolving admin role."""
|
||||
groups = ["dashboard_admin", "other_group"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "admin"
|
||||
|
||||
def test_resolve_role_member(self):
|
||||
"""Test resolving member role."""
|
||||
groups = ["dashboard_member"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "member"
|
||||
|
||||
def test_resolve_role_viewer(self):
|
||||
"""Test resolving viewer role."""
|
||||
groups = ["dashboard_viewer", "unrelated_group"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "viewer"
|
||||
|
||||
def test_resolve_role_no_match(self):
|
||||
"""Test resolving role with no matching groups."""
|
||||
groups = ["other_group", "another_group"]
|
||||
role = resolve_role(groups)
|
||||
assert role is None
|
||||
|
||||
def test_resolve_role_empty_groups(self):
|
||||
"""Test resolving role with empty groups list."""
|
||||
role = resolve_role([])
|
||||
assert role is None
|
||||
|
||||
def test_resolve_role_priority(self):
|
||||
"""Test that first matching group is used."""
|
||||
groups = ["dashboard_admin", "dashboard_member"]
|
||||
role = resolve_role(groups)
|
||||
assert role == "admin"
|
||||
|
||||
|
||||
class TestGetPages:
|
||||
"""Test page access resolution."""
|
||||
|
||||
def test_get_pages_admin_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for admin with default settings."""
|
||||
pages = get_pages("testuser", "admin")
|
||||
assert pages == "*"
|
||||
|
||||
def test_get_pages_member_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for member with default settings."""
|
||||
pages = get_pages("testuser", "member")
|
||||
assert isinstance(pages, list)
|
||||
assert "dashboard" in pages
|
||||
assert "docker" in pages
|
||||
|
||||
def test_get_pages_viewer_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for viewer with default settings."""
|
||||
pages = get_pages("testuser", "viewer")
|
||||
assert pages == ["dashboard"]
|
||||
|
||||
def test_get_pages_with_user_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages with user-specific override."""
|
||||
|
||||
# Add user override
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard", "files"]}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
pages = get_pages("customuser", "admin")
|
||||
assert pages == ["dashboard", "files"]
|
||||
|
||||
def test_get_pages_unknown_role(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages for unknown role returns empty list."""
|
||||
pages = get_pages("testuser", "unknown_role")
|
||||
assert pages == []
|
||||
|
||||
|
||||
class TestGetSidebarLinks:
|
||||
"""Test sidebar link access resolution."""
|
||||
|
||||
def test_get_sidebar_links_admin_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links for admin."""
|
||||
links = get_sidebar_links("testuser", "admin")
|
||||
assert links == "*"
|
||||
|
||||
def test_get_sidebar_links_member_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links for member."""
|
||||
links = get_sidebar_links("testuser", "member")
|
||||
assert links == "*"
|
||||
|
||||
def test_get_sidebar_links_viewer_default(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links for viewer."""
|
||||
links = get_sidebar_links("testuser", "viewer")
|
||||
assert links == ["dashboard"]
|
||||
|
||||
def test_get_sidebar_links_with_user_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links with user override."""
|
||||
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"pages": "*", "sidebar_links": ["dashboard", "docker", "files"]}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
links = get_sidebar_links("customuser", "admin")
|
||||
assert links == ["dashboard", "docker", "files"]
|
||||
|
||||
def test_get_sidebar_links_no_override(self, mock_config, temp_rbac_file):
|
||||
"""Test that user without sidebar_links override gets role default."""
|
||||
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard"]}
|
||||
# No sidebar_links specified
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
links = get_sidebar_links("customuser", "admin")
|
||||
assert links == "*" # Should fall back to admin default
|
||||
|
||||
|
||||
class TestGetSidebarOrder:
|
||||
"""Test sidebar order retrieval."""
|
||||
|
||||
def test_get_sidebar_order_no_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar order when user has no override."""
|
||||
order = get_sidebar_order("testuser")
|
||||
assert order is None
|
||||
|
||||
def test_get_sidebar_order_with_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar order with user override."""
|
||||
custom_order = {"Main": ["dashboard", "docker"], "Media": ["navidrome", "immich"]}
|
||||
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"sidebar_order": custom_order}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
order = get_sidebar_order("customuser")
|
||||
assert order == custom_order
|
||||
|
||||
|
||||
class TestDefaultRBAC:
|
||||
"""Test default RBAC configuration."""
|
||||
|
||||
def test_default_rbac_structure(self):
|
||||
"""Test that DEFAULT_RBAC has correct structure."""
|
||||
assert "role_defaults" in DEFAULT_RBAC
|
||||
assert "user_overrides" in DEFAULT_RBAC
|
||||
|
||||
assert "admin" in DEFAULT_RBAC["role_defaults"]
|
||||
assert "member" in DEFAULT_RBAC["role_defaults"]
|
||||
assert "viewer" in DEFAULT_RBAC["role_defaults"]
|
||||
|
||||
def test_default_rbac_admin_permissions(self):
|
||||
"""Test admin has full permissions by default."""
|
||||
admin = DEFAULT_RBAC["role_defaults"]["admin"]
|
||||
assert admin["pages"] == "*"
|
||||
assert admin["sidebar_links"] == "*"
|
||||
|
||||
def test_default_rbac_member_permissions(self):
|
||||
"""Test member has limited permissions."""
|
||||
member = DEFAULT_RBAC["role_defaults"]["member"]
|
||||
assert isinstance(member["pages"], list)
|
||||
assert "dashboard" in member["pages"]
|
||||
assert member["sidebar_links"] == "*"
|
||||
|
||||
def test_default_rbac_viewer_permissions(self):
|
||||
"""Test viewer has minimal permissions."""
|
||||
viewer = DEFAULT_RBAC["role_defaults"]["viewer"]
|
||||
assert viewer["pages"] == ["dashboard"]
|
||||
assert viewer["sidebar_links"] == ["dashboard"]
|
||||
|
||||
|
||||
class TestRoleGroupMap:
|
||||
"""Test role group mapping."""
|
||||
|
||||
def test_role_group_map_completeness(self):
|
||||
"""Test that ROLE_GROUP_MAP has all expected mappings."""
|
||||
assert "dashboard_admin" in ROLE_GROUP_MAP
|
||||
assert "dashboard_member" in ROLE_GROUP_MAP
|
||||
assert "dashboard_viewer" in ROLE_GROUP_MAP
|
||||
|
||||
assert ROLE_GROUP_MAP["dashboard_admin"] == "admin"
|
||||
assert ROLE_GROUP_MAP["dashboard_member"] == "member"
|
||||
assert ROLE_GROUP_MAP["dashboard_viewer"] == "viewer"
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
- CADDY_VPS_SSH_USER=ubuntu
|
||||
- MAC_SSH_HOST=192.168.31.22
|
||||
- MAC_SSH_USER=jimmyg
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- SECRET_KEY=${SECRET_KEY:-c0e8dcd74b2d70c596dfa03928f2582ca8d88af04896a82ee6c2aeeaa6bd6199}
|
||||
- ADMIN_USER=jimmy
|
||||
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
@@ -28,7 +28,9 @@ services:
|
||||
- WEBAUTHN_RP_ID=jimmygan.com
|
||||
- WEBAUTHN_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443,https://auth.jimmygan.com:8443
|
||||
- LITELLM_URL=http://litellm:4005
|
||||
- LITELLM_API_KEY=anything
|
||||
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
||||
- GITEA_DB_PASSWORD=${GITEA_DB_PASSWORD}
|
||||
volumes:
|
||||
- /volume1:/volume1
|
||||
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
|
||||
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- internal
|
||||
- nas-dashboard_internal
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -48,9 +49,10 @@ services:
|
||||
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
|
||||
- CORS_ORIGINS=https://nas.jimmygan.com
|
||||
- WEBAUTHN_RP_ID=jimmygan.com
|
||||
- WEBAUTHN_ORIGINS=https://nas.jimmygan.com,https://auth.jimmygan.com:8443
|
||||
- WEBAUTHN_ORIGINS=https://nas.jimmygan.com,https://nas.jimmygan.com:8443,https://auth.jimmygan.com:8443
|
||||
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
||||
- LITELLM_URL=http://litellm:4005
|
||||
- GITEA_DB_PASSWORD=${GITEA_DB_PASSWORD}
|
||||
volumes:
|
||||
- /volume1:/volume1
|
||||
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
|
||||
|
||||
Generated
+3128
-1
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,19 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
"build": "vite build",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@testing-library/svelte": "^5.2.3",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"jsdom": "^25.0.1",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.3.0",
|
||||
"vitest": "^2.1.8",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
import OpenClaw from "./routes/OpenClaw.svelte";
|
||||
import Settings from "./routes/Settings.svelte";
|
||||
import ChatSummary from "./routes/ChatSummary.svelte";
|
||||
import Conversations from "./routes/Conversations.svelte";
|
||||
import Security from "./routes/Security.svelte";
|
||||
import LiteLLM from "./routes/LiteLLM.svelte";
|
||||
import CcConnect from "./routes/CcConnect.svelte";
|
||||
import InfoEngine from "./routes/InfoEngine.svelte";
|
||||
import OPC from "./routes/OPC.svelte";
|
||||
import Transmission from "./routes/Transmission.svelte";
|
||||
import Login from "./routes/Login.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
|
||||
@@ -24,11 +27,14 @@
|
||||
"terminal",
|
||||
"openclaw",
|
||||
"chat-digest",
|
||||
"conversations",
|
||||
"settings",
|
||||
"security",
|
||||
"litellm",
|
||||
"cc-connect",
|
||||
"info-engine",
|
||||
"opc",
|
||||
"transmission",
|
||||
]);
|
||||
|
||||
let page = $state("dashboard");
|
||||
@@ -121,15 +127,13 @@
|
||||
// Proxy auth not available, fall through to regular auth
|
||||
}
|
||||
|
||||
// Regular token auth check
|
||||
if (!getToken()) {
|
||||
// Regular token auth check — always try refresh to get fresh cookies
|
||||
const refreshed = await tryRefreshSession();
|
||||
if (!refreshed) {
|
||||
if (!refreshed && !getToken()) {
|
||||
loading = false;
|
||||
authorized = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchUserInfo();
|
||||
@@ -199,6 +203,8 @@
|
||||
<OpenClaw />
|
||||
{:else if page === "chat-digest" && hasPageAccess("chat-digest")}
|
||||
<ChatSummary />
|
||||
{:else if page === "conversations" && hasPageAccess("conversations")}
|
||||
<Conversations />
|
||||
{:else if page === "settings" && userRole === "admin"}
|
||||
<Settings />
|
||||
{:else if page === "security" && hasPageAccess("security")}
|
||||
@@ -209,6 +215,10 @@
|
||||
<CcConnect />
|
||||
{:else if page === "info-engine" && hasPageAccess("dashboard")}
|
||||
<InfoEngine />
|
||||
{:else if page === "opc" && hasPageAccess("opc")}
|
||||
<OPC />
|
||||
{:else if page === "transmission" && hasPageAccess("transmission")}
|
||||
<Transmission />
|
||||
{:else if page !== "terminal"}
|
||||
<Dashboard />
|
||||
{/if}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
{ id: "dashboard", label: "Overview", icon: "grid" },
|
||||
{ id: "info-engine", label: "Info Engine", icon: "sparkles" },
|
||||
{ id: "litellm", label: "LiteLLM", icon: "bolt" },
|
||||
{ id: "opc", label: "OPC", icon: "kanban" },
|
||||
{ id: "docker", label: "Docker", icon: "box" },
|
||||
{ id: "files", label: "Files", icon: "folder" },
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
||||
@@ -41,12 +42,14 @@
|
||||
{ label: "Jellyfin", remoteHref: "https://media.jimmygan.com:8443", icon: "play" },
|
||||
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com:8443", icon: "headphones" },
|
||||
{ label: "Immich", remoteHref: "https://photos.jimmygan.com:8443", icon: "image" },
|
||||
{ id: "transmission", label: "Transmission", icon: "download" },
|
||||
];
|
||||
|
||||
const defaultTools = [
|
||||
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" },
|
||||
{ id: "cc-connect", label: "cc-connect", icon: "users" },
|
||||
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
|
||||
{ id: "conversations", label: "Code Sessions", icon: "code" },
|
||||
{ id: "gitea", label: "Repos", icon: "git" },
|
||||
{ label: "Gitea Web", remoteHref: "https://git.jimmygan.com:8443", icon: "git", external: true },
|
||||
{ label: "Stirling PDF", remoteHref: "https://pdf.jimmygan.com:8443", icon: "pdf", external: true },
|
||||
@@ -243,6 +246,8 @@
|
||||
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
{:else if icon === "shield"}
|
||||
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
{:else if icon === "kanban"}
|
||||
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2zm8 0h-2a2 2 0 00-2 2v8a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2z" /></svg>
|
||||
{:else if icon === "music"}
|
||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" /></svg>
|
||||
{:else if icon === "play"}
|
||||
@@ -251,10 +256,14 @@
|
||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 18v-6a9 9 0 0118 0v6M3 18a3 3 0 003 3h0a3 3 0 003-3v-2a3 3 0 00-3-3h0a3 3 0 00-3 3v2zm18 0a3 3 0 01-3 3h0a3 3 0 01-3-3v-2a3 3 0 013-3h0a3 3 0 013 3v2z" /></svg>
|
||||
{:else if icon === "image"}
|
||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
{:else if icon === "download"}
|
||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" /></svg>
|
||||
{:else if icon === "openclaw"}
|
||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
|
||||
{:else if icon === "chat"}
|
||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
|
||||
{:else if icon === "code"}
|
||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
|
||||
{:else if icon === "git"}
|
||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
|
||||
{:else if icon === "pdf"}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { getAgents, getExecutions } from "../../lib/opc-api.js";
|
||||
|
||||
let agents = $state([]);
|
||||
let executions = $state([]);
|
||||
let loading = $state(true);
|
||||
let selectedAgent = $state(null);
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [agentsRes, executionsRes] = await Promise.all([
|
||||
getAgents(),
|
||||
getExecutions({ limit: 20 })
|
||||
]);
|
||||
agents = agentsRes.items || [];
|
||||
executions = executionsRes.items || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load agent data:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentIcon(agentId) {
|
||||
const icons = {
|
||||
pm: "📋",
|
||||
cto: "💻",
|
||||
coo: "⚙️",
|
||||
ceo: "🎯",
|
||||
marketing: "📢",
|
||||
social_media: "📱"
|
||||
};
|
||||
return icons[agentId] || "🤖";
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colors = {
|
||||
pending: "bg-slate-100 text-slate-700",
|
||||
running: "bg-blue-100 text-blue-700",
|
||||
completed: "bg-emerald-100 text-emerald-700",
|
||||
failed: "bg-rose-100 text-rose-700",
|
||||
pending_approval: "bg-amber-100 text-amber-700"
|
||||
};
|
||||
return colors[status] || colors.pending;
|
||||
}
|
||||
|
||||
function getAgentExecutions(agentId) {
|
||||
return executions.filter(e => e.agent_id === agentId);
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) return "N/A";
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadData();
|
||||
// Refresh every 10 seconds
|
||||
const interval = setInterval(loadData, 10000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
|
||||
AI Agents
|
||||
</h2>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-surface-600 dark:text-surface-400">Loading agents...</div>
|
||||
{:else}
|
||||
<!-- Agent Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{#each agents as agent}
|
||||
{@const agentExecs = getAgentExecutions(agent.id)}
|
||||
{@const runningExecs = agentExecs.filter(e => e.status === "running")}
|
||||
{@const completedExecs = agentExecs.filter(e => e.status === "completed")}
|
||||
|
||||
<button
|
||||
onclick={() => selectedAgent = selectedAgent?.id === agent.id ? null : agent}
|
||||
class="bg-white dark:bg-surface-800 rounded-lg p-4 border-2 transition-all text-left hover:shadow-lg {selectedAgent?.id === agent.id ? 'border-primary-500' : 'border-surface-200 dark:border-surface-700'}"
|
||||
>
|
||||
<!-- Agent Header -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-3xl">{getAgentIcon(agent.id)}</span>
|
||||
<div>
|
||||
<h3 class="font-semibold text-surface-900 dark:text-surface-100">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<p class="text-xs text-surface-600 dark:text-surface-400">
|
||||
{agent.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if runningExecs.length > 0}
|
||||
<span class="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
<span class="animate-pulse">●</span>
|
||||
Working
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-surface-500">Idle</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Agent Stats -->
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
|
||||
<div class="text-xs text-surface-600 dark:text-surface-400">Total Tasks</div>
|
||||
<div class="text-lg font-semibold text-surface-900 dark:text-surface-100">
|
||||
{agentExecs.length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
|
||||
<div class="text-xs text-surface-600 dark:text-surface-400">Completed</div>
|
||||
<div class="text-lg font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
{completedExecs.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capabilities -->
|
||||
<div class="mt-3">
|
||||
<div class="text-xs text-surface-600 dark:text-surface-400 mb-1">Capabilities:</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each agent.capabilities.slice(0, 3) as capability}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300">
|
||||
{capability.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{/each}
|
||||
{#if agent.capabilities.length > 3}
|
||||
<span class="text-xs text-surface-500">+{agent.capabilities.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Execution Log -->
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
|
||||
<div class="p-4 border-b border-surface-200 dark:border-surface-700">
|
||||
<h3 class="font-semibold text-surface-900 dark:text-surface-100">
|
||||
{selectedAgent ? `${selectedAgent.name} Execution Log` : "Recent Executions"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-surface-200 dark:divide-surface-700 max-h-96 overflow-y-auto">
|
||||
{#each (selectedAgent ? getAgentExecutions(selectedAgent.id) : executions) as execution}
|
||||
<div class="p-4 hover:bg-surface-50 dark:hover:bg-surface-900/50">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">{getAgentIcon(execution.agent_id)}</span>
|
||||
<div>
|
||||
<div class="font-medium text-surface-900 dark:text-surface-100">
|
||||
Task #{execution.task_id}
|
||||
</div>
|
||||
<div class="text-xs text-surface-600 dark:text-surface-400">
|
||||
{formatTimestamp(execution.started_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-1 rounded {getStatusColor(execution.status)}">
|
||||
{execution.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if execution.output_result?.reasoning}
|
||||
<p class="text-sm text-surface-700 dark:text-surface-300 mb-2">
|
||||
{execution.output_result.reasoning}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if execution.actions_proposed && execution.actions_proposed.length > 0}
|
||||
<div class="text-xs text-surface-600 dark:text-surface-400">
|
||||
Actions: {execution.actions_proposed.length}
|
||||
{#each execution.actions_proposed.slice(0, 2) as action}
|
||||
<span class="ml-2 px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700">
|
||||
{action.type}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if execution.error_message}
|
||||
<div class="mt-2 text-xs text-rose-600 dark:text-rose-400">
|
||||
Error: {execution.error_message}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-8 text-center text-surface-500">
|
||||
No executions yet
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import KanbanColumn from "./KanbanColumn.svelte";
|
||||
import { moveTask } from "../../lib/opc-api.js";
|
||||
|
||||
let { tasks = [], agents = [], onEdit, onDelete, onAssign, onTaskMoved } = $props();
|
||||
|
||||
const columns = [
|
||||
{ id: "parking_lot", title: "Parking Lot", status: "parking_lot" },
|
||||
{ id: "in_progress", title: "In Progress", status: "in_progress" },
|
||||
{ id: "done", title: "Done", status: "done" }
|
||||
];
|
||||
|
||||
function getTasksByStatus(status) {
|
||||
return tasks.filter(t => t.status === status);
|
||||
}
|
||||
|
||||
async function handleDrop(taskId, newStatus) {
|
||||
try {
|
||||
await moveTask(taskId, newStatus);
|
||||
onTaskMoved?.(taskId, newStatus);
|
||||
} catch (e) {
|
||||
alert("Failed to move task: " + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 h-full">
|
||||
{#each columns as column}
|
||||
<KanbanColumn
|
||||
title={column.title}
|
||||
status={column.status}
|
||||
tasks={getTasksByStatus(column.status)}
|
||||
{agents}
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
{onAssign}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script>
|
||||
import TaskCard from "./TaskCard.svelte";
|
||||
|
||||
let {
|
||||
title,
|
||||
status,
|
||||
tasks = [],
|
||||
agents = [],
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAssign,
|
||||
onDrop
|
||||
} = $props();
|
||||
|
||||
const columnColors = {
|
||||
parking_lot: "bg-slate-100 dark:bg-slate-800 border-slate-300",
|
||||
in_progress: "bg-blue-50 dark:bg-blue-900/20 border-blue-300",
|
||||
done: "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-300"
|
||||
};
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.add("ring-2", "ring-primary-400");
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
e.currentTarget.classList.remove("ring-2", "ring-primary-400");
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove("ring-2", "ring-primary-400");
|
||||
|
||||
const taskId = parseInt(e.dataTransfer.getData("taskId"));
|
||||
const fromStatus = e.dataTransfer.getData("fromStatus");
|
||||
|
||||
if (fromStatus !== status) {
|
||||
onDrop?.(taskId, status);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(e, task) {
|
||||
e.dataTransfer.setData("taskId", task.id);
|
||||
e.dataTransfer.setData("fromStatus", task.status);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Column header -->
|
||||
<div class="flex items-center justify-between mb-3 pb-2 border-b border-surface-300 dark:border-surface-600">
|
||||
<h3 class="font-semibold text-surface-900 dark:text-surface-100">
|
||||
{title}
|
||||
</h3>
|
||||
<span class="text-sm text-surface-600 dark:text-surface-400 bg-surface-200 dark:bg-surface-700 px-2 py-0.5 rounded-full">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
class="flex-1 min-h-[200px] p-2 rounded-lg border-2 border-dashed transition-all {columnColors[status] || columnColors.parking_lot}"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
{#if tasks.length === 0}
|
||||
<div class="flex items-center justify-center h-32 text-surface-400 dark:text-surface-500 text-sm">
|
||||
Drop tasks here
|
||||
</div>
|
||||
{:else}
|
||||
{#each tasks as task (task.id)}
|
||||
<div draggable="true" ondragstart={(e) => handleDragStart(e, task)}>
|
||||
<TaskCard
|
||||
{task}
|
||||
{agents}
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
{onAssign}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script>
|
||||
import { deleteTask, assignTask, getTaskTime } from "../../lib/opc-api.js";
|
||||
|
||||
let { task, agents = [], onEdit, onDelete, onAssign } = $props();
|
||||
|
||||
let showActions = $state(false);
|
||||
let timeInfo = $state(null);
|
||||
|
||||
const priorityColors = {
|
||||
low: "bg-slate-200 text-slate-700",
|
||||
medium: "bg-blue-100 text-blue-700",
|
||||
high: "bg-amber-100 text-amber-700",
|
||||
urgent: "bg-rose-100 text-rose-700"
|
||||
};
|
||||
|
||||
async function loadTimeInfo() {
|
||||
if (task.status === "in_progress") {
|
||||
try {
|
||||
timeInfo = await getTaskTime(task.id);
|
||||
} catch (e) {
|
||||
console.error("Failed to load time info:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadTimeInfo();
|
||||
});
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm(`Delete task "${task.title}"?`)) {
|
||||
try {
|
||||
await deleteTask(task.id);
|
||||
onDelete?.(task.id);
|
||||
} catch (e) {
|
||||
alert("Failed to delete task: " + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignAgent(agentId) {
|
||||
try {
|
||||
await assignTask(task.id, agentId, "agent");
|
||||
onAssign?.(task.id, agentId, "agent");
|
||||
} catch (e) {
|
||||
alert("Failed to assign agent: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentIcon(agentId) {
|
||||
const icons = {
|
||||
pm: "📋",
|
||||
cto: "💻",
|
||||
coo: "⚙️",
|
||||
ceo: "🎯",
|
||||
marketing: "📢",
|
||||
social_media: "📱"
|
||||
};
|
||||
return icons[agentId] || "🤖";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-surface-800 rounded-lg p-3 mb-2 shadow-sm border border-surface-200 dark:border-surface-700 cursor-move hover:shadow-md transition-shadow"
|
||||
onmouseenter={() => showActions = true}
|
||||
onmouseleave={() => showActions = false}
|
||||
>
|
||||
<!-- Priority badge -->
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded {priorityColors[task.priority] || priorityColors.medium}">
|
||||
{task.priority}
|
||||
</span>
|
||||
{#if showActions}
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
onclick={() => onEdit?.(task)}
|
||||
class="text-xs text-surface-600 hover:text-primary-600 dark:text-surface-400"
|
||||
title="Edit"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="text-xs text-surface-600 hover:text-rose-600 dark:text-surface-400"
|
||||
title="Delete"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h4 class="font-medium text-surface-900 dark:text-surface-100 mb-1 text-sm">
|
||||
{task.title}
|
||||
</h4>
|
||||
|
||||
<!-- Description preview -->
|
||||
{#if task.description}
|
||||
<p class="text-xs text-surface-600 dark:text-surface-400 mb-2 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if task.tags && task.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
{#each task.tags as tag}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Assignee and time -->
|
||||
<div class="flex items-center justify-between text-xs text-surface-600 dark:text-surface-400">
|
||||
<div class="flex items-center gap-1">
|
||||
{#if task.assigned_to}
|
||||
{#if task.assigned_type === "agent"}
|
||||
<span title={task.assigned_to}>
|
||||
{getAgentIcon(task.assigned_to)}
|
||||
</span>
|
||||
{:else}
|
||||
<span>👤 {task.assigned_to}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => showActions = true}
|
||||
class="text-surface-500 hover:text-primary-600"
|
||||
title="Assign"
|
||||
>
|
||||
Unassigned
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if timeInfo && timeInfo.total_hours > 0}
|
||||
<span class="text-xs text-surface-500">
|
||||
⏱️ {timeInfo.total_hours}h
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Due date -->
|
||||
{#if task.due_date}
|
||||
<div class="text-xs text-surface-500 mt-1">
|
||||
📅 {new Date(task.due_date).toLocaleDateString()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick assign to agent (when hovering) -->
|
||||
{#if showActions && !task.assigned_to}
|
||||
<div class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-600 mb-1">Assign to agent:</div>
|
||||
<div class="flex gap-1">
|
||||
{#each agents as agent}
|
||||
<button
|
||||
onclick={() => handleAssignAgent(agent.id)}
|
||||
class="text-lg hover:scale-110 transition-transform"
|
||||
title={agent.name}
|
||||
>
|
||||
{getAgentIcon(agent.id)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script>
|
||||
import { createTask, updateTask } from "../../lib/opc-api.js";
|
||||
|
||||
let { task = null, agents = [], onClose, onSave } = $props();
|
||||
|
||||
let isEdit = $state(!!task);
|
||||
let formData = $state({
|
||||
title: task?.title || "",
|
||||
description: task?.description || "",
|
||||
status: task?.status || "parking_lot",
|
||||
priority: task?.priority || "medium",
|
||||
assigned_to: task?.assigned_to || "",
|
||||
assigned_type: task?.assigned_type || "human",
|
||||
tags: task?.tags || [],
|
||||
due_date: task?.due_date ? task.due_date.split("T")[0] : ""
|
||||
});
|
||||
|
||||
let tagInput = $state("");
|
||||
let saving = $state(false);
|
||||
|
||||
function addTag() {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
formData.tags = [...formData.tags, tagInput.trim()];
|
||||
tagInput = "";
|
||||
}
|
||||
}
|
||||
|
||||
function removeTag(tag) {
|
||||
formData.tags = formData.tags.filter(t => t !== tag);
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
saving = true;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
due_date: formData.due_date ? new Date(formData.due_date).toISOString() : null,
|
||||
assigned_to: formData.assigned_to || null
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await updateTask(task.id, payload);
|
||||
} else {
|
||||
await createTask(payload);
|
||||
}
|
||||
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
} catch (e) {
|
||||
alert("Failed to save task: " + e.message);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Modal backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onclick={(e) => e.target === e.currentTarget && onClose?.()}
|
||||
>
|
||||
<!-- Modal content -->
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-surface-200 dark:border-surface-700">
|
||||
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
|
||||
{isEdit ? "Edit Task" : "Create Task"}
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form onsubmit={handleSubmit} class="p-4 space-y-4">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formData.title}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
bind:value={formData.description}
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
placeholder="Task description"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Status and Priority -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
bind:value={formData.status}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
>
|
||||
<option value="parking_lot">Parking Lot</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
bind:value={formData.priority}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignee -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Assign to
|
||||
</label>
|
||||
<select
|
||||
bind:value={formData.assigned_type}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
>
|
||||
<option value="human">Human</option>
|
||||
<option value="agent">Agent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
{formData.assigned_type === "agent" ? "Agent" : "Username"}
|
||||
</label>
|
||||
{#if formData.assigned_type === "agent"}
|
||||
<select
|
||||
bind:value={formData.assigned_to}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{#each agents as agent}
|
||||
<option value={agent.id}>{agent.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formData.assigned_to}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
placeholder="Username"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Due date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={formData.due_date}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Tags
|
||||
</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tagInput}
|
||||
onkeydown={(e) => e.key === "Enter" && (e.preventDefault(), addTag())}
|
||||
class="flex-1 px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
placeholder="Add tag and press Enter"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={addTag}
|
||||
class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{#if formData.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each formData.tags as tag}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded text-sm">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(tag)}
|
||||
class="hover:text-primary-900 dark:hover:text-primary-100"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-surface-200 dark:border-surface-700">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="px-4 py-2 text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,3 +197,36 @@ export function getInfoEngineItem(id) {
|
||||
export function put(path, data) {
|
||||
return request(path, { method: "PUT", json: data });
|
||||
}
|
||||
|
||||
export async function download(path, filename) {
|
||||
const headers = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
// Get a temporary download token
|
||||
const tokenResponse = await fetch(`${BASE}/files/download-token?path=${encodeURIComponent(path)}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
console.error(`Download token failed: ${tokenResponse.status} - ${errorText}`);
|
||||
throw new Error(`Download failed: ${tokenResponse.status}`);
|
||||
}
|
||||
|
||||
const { token: downloadToken } = await tokenResponse.json();
|
||||
|
||||
// Use the token to trigger browser download (no memory loading)
|
||||
const downloadUrl = `${BASE}/files/download?token=${downloadToken}`;
|
||||
const a = document.createElement("a");
|
||||
a.href = downloadUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} catch (e) {
|
||||
console.error("Download error:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* OPC API Client
|
||||
*/
|
||||
import { get, post, put, del } from "./api.js";
|
||||
|
||||
// Tasks
|
||||
export async function getTasks(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
if (filters.assigned_to) params.append("assigned_to", filters.assigned_to);
|
||||
if (filters.project_id) params.append("project_id", filters.project_id);
|
||||
if (filters.priority) params.append("priority", filters.priority);
|
||||
if (filters.tags) params.append("tags", filters.tags.join(","));
|
||||
if (filters.limit) params.append("limit", filters.limit);
|
||||
if (filters.offset) params.append("offset", filters.offset);
|
||||
|
||||
const query = params.toString();
|
||||
return get(`/opc/tasks${query ? "?" + query : ""}`);
|
||||
}
|
||||
|
||||
export async function createTask(task) {
|
||||
return post("/opc/tasks", task);
|
||||
}
|
||||
|
||||
export async function updateTask(taskId, updates) {
|
||||
return put(`/opc/tasks/${taskId}`, updates);
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId) {
|
||||
return del(`/opc/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
export async function moveTask(taskId, status) {
|
||||
return put(`/opc/tasks/${taskId}/move`, { status });
|
||||
}
|
||||
|
||||
export async function assignTask(taskId, assigned_to, assigned_type = "human") {
|
||||
return post(`/opc/tasks/${taskId}/assign`, { assigned_to, assigned_type });
|
||||
}
|
||||
|
||||
export async function getTaskHistory(taskId) {
|
||||
return get(`/opc/tasks/${taskId}/history`);
|
||||
}
|
||||
|
||||
export async function getTaskTime(taskId) {
|
||||
return get(`/opc/tasks/${taskId}/time`);
|
||||
}
|
||||
|
||||
// Agents
|
||||
export async function getAgents(enabledOnly = true) {
|
||||
return get(`/opc/agents?enabled_only=${enabledOnly}`);
|
||||
}
|
||||
|
||||
export async function getAgent(agentId) {
|
||||
return get(`/opc/agents/${agentId}`);
|
||||
}
|
||||
|
||||
export async function triggerAgent(agentId, taskId) {
|
||||
return post(`/opc/agents/${agentId}/execute?task_id=${taskId}`);
|
||||
}
|
||||
|
||||
// Executions
|
||||
export async function getExecutions(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.task_id) params.append("task_id", filters.task_id);
|
||||
if (filters.agent_id) params.append("agent_id", filters.agent_id);
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
if (filters.limit) params.append("limit", filters.limit);
|
||||
|
||||
const query = params.toString();
|
||||
return get(`/opc/executions${query ? "?" + query : ""}`);
|
||||
}
|
||||
|
||||
export async function getExecution(executionId) {
|
||||
return get(`/opc/executions/${executionId}`);
|
||||
}
|
||||
|
||||
export async function approveExecution(executionId, approved) {
|
||||
return post(`/opc/executions/${executionId}/approve`, { approved });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* OPC WebSocket Client - Real-time updates
|
||||
*/
|
||||
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let listeners = new Set();
|
||||
|
||||
export function connect() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/opc`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("OPC WebSocket connected");
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
// Send ping every 30 seconds to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send("ping");
|
||||
} else {
|
||||
clearInterval(pingInterval);
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
notifyListeners(message);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse WebSocket message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log("OPC WebSocket disconnected");
|
||||
ws = null;
|
||||
|
||||
// Reconnect after 5 seconds
|
||||
reconnectTimer = setTimeout(() => {
|
||||
console.log("Reconnecting WebSocket...");
|
||||
connect();
|
||||
}, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribe(callback) {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function notifyListeners(message) {
|
||||
listeners.forEach((callback) => {
|
||||
try {
|
||||
callback(message);
|
||||
} catch (e) {
|
||||
console.error("Listener error:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { get, post } from "../lib/api.js";
|
||||
|
||||
let dates = $state([]);
|
||||
let selectedDate = $state("");
|
||||
let summary = $state(null);
|
||||
let conversations = $state([]);
|
||||
let selectedConversation = $state(null);
|
||||
let messages = $state([]);
|
||||
let searchQuery = $state("");
|
||||
let searchResults = $state([]);
|
||||
let stats = $state(null);
|
||||
let loading = $state(false);
|
||||
let triggering = $state(false);
|
||||
let error = $state("");
|
||||
let view = $state("summary"); // summary, list, detail, search, stats
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
dates = await get("/api/conversations/dates");
|
||||
if (dates.length) selectDate(dates[0]);
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
error = e.body?.detail || "Failed to load dates";
|
||||
}
|
||||
});
|
||||
|
||||
async function selectDate(d) {
|
||||
selectedDate = d;
|
||||
view = "summary";
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
summary = await get(`/api/conversations/summary/${d}`);
|
||||
conversations = await get(`/api/conversations/list?date=${d}`);
|
||||
} catch (e) {
|
||||
summary = null;
|
||||
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectConversation(sessionId) {
|
||||
view = "detail";
|
||||
loading = true;
|
||||
try {
|
||||
selectedConversation = await get(`/api/conversations/${sessionId}`);
|
||||
messages = await get(`/api/conversations/${sessionId}/messages`);
|
||||
} catch (e) {
|
||||
error = "Failed to load conversation";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!searchQuery || searchQuery.length < 2) return;
|
||||
view = "search";
|
||||
loading = true;
|
||||
try {
|
||||
searchResults = await get(`/api/conversations/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
} catch (e) {
|
||||
error = "Search failed";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await get("/api/conversations/stats");
|
||||
} catch (e) {
|
||||
console.error("Failed to load stats", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function trigger() {
|
||||
triggering = true;
|
||||
try {
|
||||
await post("/api/conversations/trigger");
|
||||
setTimeout(() => selectDate(selectedDate), 5000);
|
||||
} catch (e) {
|
||||
error = "Trigger failed";
|
||||
} finally {
|
||||
triggering = false;
|
||||
}
|
||||
}
|
||||
|
||||
function markdownToHtml(md) {
|
||||
if (!md) return "";
|
||||
return md
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
|
||||
.replace(/\n{2,}/g, '<br><br>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function formatTokens(n) {
|
||||
return n ? n.toLocaleString() : "0";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Code Sessions</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => view = "stats"} class="px-3 py-1.5 text-sm font-medium rounded-lg bg-surface-100 hover:bg-surface-200 dark:bg-surface-700 dark:hover:bg-surface-600 transition-colors">
|
||||
Stats
|
||||
</button>
|
||||
<button onclick={trigger} disabled={triggering}
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
|
||||
{triggering ? "Generating..." : "Generate Summary"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === "Enter" && search()}
|
||||
placeholder="Search conversations..."
|
||||
class="w-full px-4 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date Selector -->
|
||||
{#if view !== "search" && view !== "stats"}
|
||||
<div class="flex gap-2 mb-6 flex-wrap">
|
||||
{#each dates as d}
|
||||
<button onclick={() => selectDate(d)}
|
||||
class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
|
||||
{d}
|
||||
</button>
|
||||
{/each}
|
||||
{#if !dates.length && !error}
|
||||
<p class="text-sm text-surface-400">No conversations yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
|
||||
{:else if view === "summary" && summary}
|
||||
<!-- Summary View -->
|
||||
<div class="mb-4 grid grid-cols-4 gap-3">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-400">Conversations</div>
|
||||
<div class="text-xl font-bold text-surface-900 dark:text-white">{summary.conversation_count}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-400">Messages</div>
|
||||
<div class="text-xl font-bold text-surface-900 dark:text-white">{summary.total_messages}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-400">Tokens</div>
|
||||
<div class="text-xl font-bold text-surface-900 dark:text-white">{formatTokens(summary.total_tokens)}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-400">Projects</div>
|
||||
<div class="text-sm font-medium text-surface-900 dark:text-white truncate">{summary.projects?.split(',')[0] || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700 mb-4">
|
||||
{@html markdownToHtml(summary.content)}
|
||||
</div>
|
||||
|
||||
<!-- Conversation List -->
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
|
||||
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
|
||||
Conversations ({conversations.length})
|
||||
</div>
|
||||
{#each conversations as conv}
|
||||
<button
|
||||
onclick={() => selectConversation(conv.session_id)}
|
||||
class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
|
||||
<div class="text-xs text-surface-400 truncate">{conv.project_path?.split('/').pop() || 'unknown'}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-surface-400">
|
||||
<span>{conv.message_count} msgs</span>
|
||||
<span>{formatTokens(conv.total_tokens)} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if view === "detail" && selectedConversation}
|
||||
<!-- Conversation Detail -->
|
||||
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back to summary</button>
|
||||
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl p-4 border border-surface-200 dark:border-surface-700 mb-4">
|
||||
<h3 class="text-lg font-bold text-surface-900 dark:text-white mb-2">{selectedConversation.slug || 'Untitled'}</h3>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><span class="text-surface-400">Project:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.project_path?.split('/').pop()}</span></div>
|
||||
<div><span class="text-surface-400">Branch:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.git_branch || 'N/A'}</span></div>
|
||||
<div><span class="text-surface-400">Messages:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.message_count}</span></div>
|
||||
<div><span class="text-surface-400">Tokens:</span> <span class="text-surface-700 dark:text-surface-300">{formatTokens(selectedConversation.total_input_tokens + selectedConversation.total_output_tokens)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="space-y-3">
|
||||
{#each messages as msg}
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold uppercase {msg.role === 'user' ? 'text-blue-600' : 'text-green-600'}">{msg.role}</span>
|
||||
<span class="text-xs text-surface-400">{msg.timestamp?.slice(11, 19)}</span>
|
||||
</div>
|
||||
<div class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap">{msg.content?.slice(0, 500)}{msg.content?.length > 500 ? '...' : ''}</div>
|
||||
{#if msg.tool_calls?.length}
|
||||
<div class="mt-2 pt-2 border-t border-surface-100 dark:border-surface-700">
|
||||
<div class="text-xs font-medium text-surface-400 mb-1">Tools: {msg.tool_calls.map(t => t.name).join(', ')}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.input_tokens || msg.output_tokens}
|
||||
<div class="mt-2 text-xs text-surface-400">
|
||||
{msg.input_tokens ? `${msg.input_tokens} in` : ''} {msg.output_tokens ? `${msg.output_tokens} out` : ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if view === "search"}
|
||||
<!-- Search Results -->
|
||||
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
|
||||
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
|
||||
Search Results ({searchResults.length})
|
||||
</div>
|
||||
{#each searchResults as conv}
|
||||
<button
|
||||
onclick={() => selectConversation(conv.session_id)}
|
||||
class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
|
||||
<div class="text-xs text-surface-400">{conv.started_at?.slice(0, 10)} • {conv.project_path?.split('/').pop()}</div>
|
||||
</div>
|
||||
<div class="text-xs text-surface-400">{conv.message_count} msgs</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if searchResults.length === 0}
|
||||
<div class="px-4 py-8 text-center text-sm text-surface-400">No results found</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if view === "stats" && stats}
|
||||
<!-- Statistics -->
|
||||
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-400">Total Conversations</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_conversations}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-400">Total Messages</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_messages}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-400">Total Tokens</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-white">{formatTokens(stats.total_tokens)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Projects</h3>
|
||||
{#each stats.top_projects as proj}
|
||||
<div class="flex justify-between text-sm py-1">
|
||||
<span class="text-surface-600 dark:text-surface-300 truncate">{proj.path?.split('/').pop()}</span>
|
||||
<span class="text-surface-400">{proj.count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Tools</h3>
|
||||
{#each stats.top_tools as tool}
|
||||
<div class="flex justify-between text-sm py-1">
|
||||
<span class="text-surface-600 dark:text-surface-300">{tool.name}</span>
|
||||
<span class="text-surface-400">{tool.count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,11 +1,13 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { get, del, upload } from "../lib/api.js";
|
||||
import { get, del, upload, download } from "../lib/api.js";
|
||||
|
||||
let entries = $state([]);
|
||||
let currentPath = $state("");
|
||||
let loading = $state(true);
|
||||
let uploading = $state(false);
|
||||
let downloading = $state(false);
|
||||
let error = $state("");
|
||||
let fileInput;
|
||||
|
||||
async function browse(path) {
|
||||
@@ -29,9 +31,18 @@
|
||||
browse(currentPath);
|
||||
}
|
||||
|
||||
function downloadUrl(name) {
|
||||
async function handleDownload(name) {
|
||||
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||
return `/api/files/download?path=${encodeURIComponent(path)}`;
|
||||
downloading = true;
|
||||
error = "";
|
||||
try {
|
||||
await download(path, name);
|
||||
} catch (e) {
|
||||
error = `Failed to download "${name}": ${e.message}`;
|
||||
console.error("Download failed:", e);
|
||||
} finally {
|
||||
downloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
@@ -84,6 +95,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
{#if error}
|
||||
<div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-lg px-4 py-3 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-rose-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-rose-700 dark:text-rose-300">{error}</p>
|
||||
</div>
|
||||
<button onclick={() => error = ""} class="text-rose-400 hover:text-rose-600 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="flex items-center gap-1 text-sm flex-wrap">
|
||||
<button class="text-primary-600 hover:text-primary-700 font-medium transition-colors" onclick={() => browse("")}>volume1</button>
|
||||
@@ -135,9 +163,11 @@
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
|
||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div class="flex items-center justify-end gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
||||
{#if !e.is_dir}
|
||||
<a href={downloadUrl(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors">DL</a>
|
||||
<button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors disabled:opacity-50" disabled={downloading}>
|
||||
{downloading ? "..." : "DL"}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="text-[11px] text-rose-400 hover:text-rose-600 font-medium transition-colors" onclick={() => remove(e.name)}>Del</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import KanbanBoard from "../components/opc/KanbanBoard.svelte";
|
||||
import TaskModal from "../components/opc/TaskModal.svelte";
|
||||
import AgentPanel from "../components/opc/AgentPanel.svelte";
|
||||
import { getTasks, getAgents } from "../lib/opc-api.js";
|
||||
import * as opcWs from "../lib/opc-ws.js";
|
||||
|
||||
let tasks = $state([]);
|
||||
let agents = $state([]);
|
||||
let loading = $state(true);
|
||||
let showTaskModal = $state(false);
|
||||
let editingTask = $state(null);
|
||||
let showAgentPanel = $state(
|
||||
typeof localStorage !== 'undefined'
|
||||
? localStorage.getItem('opc_showAgentPanel') !== 'false'
|
||||
: true
|
||||
);
|
||||
let unsubscribe = null;
|
||||
|
||||
// Persist agent panel visibility
|
||||
$effect(() => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('opc_showAgentPanel', showAgentPanel);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [tasksRes, agentsRes] = await Promise.all([
|
||||
getTasks(),
|
||||
getAgents()
|
||||
]);
|
||||
tasks = tasksRes.items || [];
|
||||
agents = agentsRes.items || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load data:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(message) {
|
||||
if (message.type === "task_update") {
|
||||
const { task_id, action, data } = message;
|
||||
|
||||
if (action === "created") {
|
||||
tasks = [...tasks, data];
|
||||
} else if (action === "updated" || action === "moved") {
|
||||
tasks = tasks.map(t => t.id === task_id ? data : t);
|
||||
} else if (action === "deleted") {
|
||||
tasks = tasks.filter(t => t.id !== task_id);
|
||||
}
|
||||
} else if (message.type === "agent_execution") {
|
||||
// Could show agent status in UI
|
||||
console.log("Agent execution update:", message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateTask() {
|
||||
editingTask = null;
|
||||
showTaskModal = true;
|
||||
}
|
||||
|
||||
function handleEditTask(task) {
|
||||
editingTask = task;
|
||||
showTaskModal = true;
|
||||
}
|
||||
|
||||
function handleDeleteTask(taskId) {
|
||||
tasks = tasks.filter(t => t.id !== taskId);
|
||||
}
|
||||
|
||||
function handleAssignTask(taskId, assignedTo, assignedType) {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.assigned_to = assignedTo;
|
||||
task.assigned_type = assignedType;
|
||||
tasks = [...tasks];
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskMoved(taskId, newStatus) {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.status = newStatus;
|
||||
tasks = [...tasks];
|
||||
}
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
showTaskModal = false;
|
||||
editingTask = null;
|
||||
}
|
||||
|
||||
function handleModalSave() {
|
||||
loadData();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadData();
|
||||
|
||||
// Connect WebSocket
|
||||
opcWs.connect();
|
||||
unsubscribe = opcWs.subscribe(handleWebSocketMessage);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
opcWs.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100">
|
||||
OPC Management
|
||||
</h1>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-1">
|
||||
Manage your tasks with AI agents
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => showAgentPanel = !showAgentPanel}
|
||||
class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600 flex items-center gap-2"
|
||||
>
|
||||
<span>🤖</span>
|
||||
<span>{showAgentPanel ? "Hide" : "Show"} Agents</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={handleCreateTask}
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
||||
>
|
||||
<span>➕</span>
|
||||
<span>New Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">Total Tasks</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100 mt-1">
|
||||
{tasks.length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">Parking Lot</div>
|
||||
<div class="text-2xl font-bold text-slate-600 dark:text-slate-400 mt-1">
|
||||
{tasks.filter(t => t.status === "parking_lot").length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">In Progress</div>
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
|
||||
{tasks.filter(t => t.status === "in_progress").length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">Done</div>
|
||||
<div class="text-2xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
|
||||
{tasks.filter(t => t.status === "done").length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board -->
|
||||
{#if loading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-surface-600 dark:text-surface-400">Loading...</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<KanbanBoard
|
||||
{tasks}
|
||||
{agents}
|
||||
onEdit={handleEditTask}
|
||||
onDelete={handleDeleteTask}
|
||||
onAssign={handleAssignTask}
|
||||
onTaskMoved={handleTaskMoved}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Agent Panel -->
|
||||
{#if showAgentPanel}
|
||||
<div class="mt-6">
|
||||
<AgentPanel />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Task Modal -->
|
||||
{#if showTaskModal}
|
||||
<TaskModal
|
||||
task={editingTask}
|
||||
{agents}
|
||||
onClose={handleModalClose}
|
||||
onSave={handleModalSave}
|
||||
/>
|
||||
{/if}
|
||||
@@ -190,18 +190,27 @@
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
let heartbeat = null;
|
||||
let pongTimeout = null;
|
||||
let reconnectTimer = null;
|
||||
let closedManually = false;
|
||||
let reconnecting = false;
|
||||
let authRecoveryInFlight = false;
|
||||
let reconnectAttempts = 0;
|
||||
let consecutiveAuthFailures = 0;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const MAX_AUTH_FAILURES = 3;
|
||||
const PING_INTERVAL = 30000; // 30s between pings
|
||||
const PONG_TIMEOUT = 10000; // 10s to receive pong
|
||||
|
||||
function clearHeartbeat() {
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
heartbeat = null;
|
||||
}
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthExpired(reason = "Session expired — please log in again") {
|
||||
@@ -257,6 +266,8 @@
|
||||
}
|
||||
|
||||
async function connect(forceRefresh = false) {
|
||||
let d = tabData.get(tabId);
|
||||
|
||||
if (forceRefresh) {
|
||||
const refreshed = await tryRefreshSession();
|
||||
if (!refreshed) {
|
||||
@@ -272,6 +283,7 @@
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
consecutiveAuthFailures = 0; // Reset auth failure counter on successful connection
|
||||
updateTab(tabId, {
|
||||
connected: true,
|
||||
error: "",
|
||||
@@ -280,8 +292,18 @@
|
||||
sendSize(ws);
|
||||
clearHeartbeat();
|
||||
heartbeat = setInterval(() => {
|
||||
if (ws.readyState === 1) ws.send("__ping__");
|
||||
}, 12000);
|
||||
if (ws.readyState === 1) {
|
||||
ws.send("__ping__");
|
||||
// Set timeout to detect missing pong
|
||||
pongTimeout = setTimeout(() => {
|
||||
// Check if still the same WebSocket instance and still open
|
||||
const currentTab = tabData.get(tabId);
|
||||
if (currentTab?.ws === ws && ws.readyState === 1) {
|
||||
ws.close(1000, "keepalive ping timeout");
|
||||
}
|
||||
}, PONG_TIMEOUT);
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
const d = tabData.get(tabId);
|
||||
if (d) {
|
||||
d.ws = ws;
|
||||
@@ -293,6 +315,16 @@
|
||||
}
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
// Check for text pong response
|
||||
if (typeof e.data === 'string' && e.data === '__pong__') {
|
||||
// Clear the pong timeout - connection is alive
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new Uint8Array(e.data);
|
||||
const isPersistenceMessage = tab.persistent
|
||||
&& data.length >= PERSISTENCE_STATUS_PREFIX_BYTES.length
|
||||
@@ -309,28 +341,46 @@
|
||||
};
|
||||
ws.onclose = async (e) => {
|
||||
const reason = e.reason || `Connection closed (code ${e.code})`;
|
||||
const d = tabData.get(tabId);
|
||||
if (d) d.ws = null;
|
||||
if (e.code === 1008 && reason === "Unauthorized") {
|
||||
const tabState = tabData.get(tabId);
|
||||
if (tabState) tabState.ws = null;
|
||||
|
||||
// Handle auth failures (code 1008)
|
||||
if (e.code === 1008) {
|
||||
consecutiveAuthFailures++;
|
||||
|
||||
// Stop after MAX_AUTH_FAILURES consecutive auth failures
|
||||
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
|
||||
handleAuthExpired(`Session expired after ${MAX_AUTH_FAILURES} attempts — please refresh page`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to refresh session
|
||||
if (!closedManually && !authRecoveryInFlight) {
|
||||
authRecoveryInFlight = true;
|
||||
try {
|
||||
const refreshed = await tryRefreshSession();
|
||||
authRecoveryInFlight = false;
|
||||
if (refreshed && !closedManually) {
|
||||
reconnecting = true;
|
||||
updateTab(tabId, {
|
||||
connected: false,
|
||||
error: "Refreshing session...",
|
||||
error: `Refreshing session (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES})...`,
|
||||
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
||||
});
|
||||
term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
|
||||
void connect(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Session refresh failed:", err);
|
||||
} finally {
|
||||
authRecoveryInFlight = false;
|
||||
}
|
||||
}
|
||||
handleAuthExpired("Session expired — please log in again");
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-auth failures: continue with normal reconnection
|
||||
if (!closedManually) scheduleReconnect(reason);
|
||||
else {
|
||||
clearHeartbeat();
|
||||
@@ -339,13 +389,13 @@
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
const d = tabData.get(tabId);
|
||||
if (d) d.ws = null;
|
||||
const tabState = tabData.get(tabId);
|
||||
if (tabState) tabState.ws = null;
|
||||
if (!closedManually) scheduleReconnect("Connection failed");
|
||||
else updateTab(tabId, { connected: false, error: "Connection failed" });
|
||||
};
|
||||
|
||||
const d = tabData.get(tabId);
|
||||
d = tabData.get(tabId);
|
||||
if (d) {
|
||||
d.ws = ws;
|
||||
d.heartbeat = heartbeat;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user