29 Commits

Author SHA1 Message Date
cbaff5614a feat(docker): add script to run Docker container for calminer application
Some checks failed
CI / lint (push) Successful in 17s
Deploy - Coolify / deploy (push) Failing after 5s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 2m18s
2025-11-15 19:58:55 +01:00
f9feb51d33 refactor(docker): update .gitignore for devcontainer files and remove version from docker-compose
Some checks failed
CI / lint (push) Successful in 16s
Deploy - Coolify / deploy (push) Failing after 5s
CI / test (push) Successful in 1m4s
CI / build (push) Successful in 2m19s
2025-11-15 15:41:43 +01:00
eb2687829f refactor(navigation): remove legacy navigation.js and integrate logic into navigation_sidebar.js
Some checks failed
CI / lint (push) Successful in 17s
Deploy - Coolify / deploy (push) Failing after 5s
CI / test (push) Successful in 1m21s
CI / build (push) Successful in 2m25s
2025-11-15 13:53:50 +01:00
ea101d1695 Merge branch 'main' of https://git.allucanget.biz/allucanget/calminer
Some checks failed
CI / lint (push) Successful in 17s
Deploy - Coolify / deploy (push) Failing after 5s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 2m15s
2025-11-14 21:22:37 +01:00
722f93b41c refactor(ci): remove deployment context capture and simplify API call for Coolify deployment 2025-11-14 21:20:37 +01:00
e2e5e12f46 Merge pull request 'merge develop to main' (#13) from develop into main
Some checks failed
Deploy - Coolify / deploy (push) Failing after 6s
CI / lint (push) Successful in 17s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 2m16s
Reviewed-on: #13
2025-11-14 20:49:10 +01:00
4e60168837 Merge https://git.allucanget.biz/allucanget/calminer into develop
All checks were successful
CI / lint (push) Successful in 16s
CI / lint (pull_request) Successful in 16s
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m2s
CI / build (push) Successful in 1m49s
CI / build (pull_request) Successful in 1m51s
2025-11-14 20:32:03 +01:00
dae3b59af9 feat(ci): add Kubernetes deployment toggle and update conditions for deployment steps
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 1m53s
CI / lint (pull_request) Successful in 16s
CI / test (pull_request) Successful in 1m3s
CI / build (pull_request) Successful in 1m51s
2025-11-14 20:14:53 +01:00
839399363e fix(ci): update registry handling and add image push step in CI workflow
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m4s
CI / build (push) Successful in 1m45s
2025-11-14 20:08:26 +01:00
fa8a065138 feat(ci): enhance CI workflow with metadata outputs and add Coolify deployment workflow
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 1m48s
2025-11-14 19:55:06 +01:00
cd0c0ab416 fix(ci-build): update conditions for push permissions in CI workflow
Some checks failed
CI / lint (push) Failing after 1s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
2025-11-14 19:21:48 +01:00
854b1ac713 Merge pull request 'feat:v2' (#12) from develop into main
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 2m17s
Reviewed-on: #12
2025-11-14 18:02:54 +01:00
25fd13ce69 Merge branch 'main' into develop
All checks were successful
CI / lint (push) Successful in 16s
CI / lint (pull_request) Successful in 16s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 1m56s
CI / test (pull_request) Successful in 1m3s
CI / build (pull_request) Successful in 1m51s
2025-11-14 18:02:43 +01:00
0fec805db1 Delete templates/dashboard.html
Some checks failed
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-14 18:02:33 +01:00
3746062819 chore: remove cicache workflow file
All checks were successful
CI / lint (push) Successful in 17s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 1m54s
CI / lint (pull_request) Successful in 15s
CI / test (pull_request) Successful in 1m2s
CI / build (pull_request) Successful in 1m46s
2025-11-14 16:34:17 +01:00
958c165721 chore: add .gitattributes for text handling and line endings
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m4s
CI / build (push) Successful in 1m56s
CI / deploy (push) Has been skipped
2025-11-14 14:21:16 +01:00
6e835c83eb fix(Dockerfile): implement fallback mechanisms for apt update and install
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m2s
CI / build (push) Successful in 1m49s
CI / deploy (push) Has been skipped
2025-11-14 14:12:02 +01:00
75924fca84 feat(ci): add CI workflows for linting, testing, and building
Some checks failed
CI / lint (push) Successful in 15s
CI / test (push) Successful in 1m2s
CI / build (push) Failing after 29s
CI / deploy (push) Has been skipped
2025-11-14 13:45:10 +01:00
ac9ffddbde fix(ci): downgrade upload-artifact action to v3 for compatibility
Some checks failed
CI / build (push) Failing after 41s
CI / deploy (push) Has been skipped
CI / lint (push) Successful in 15s
CI / test (push) Successful in 1m12s
2025-11-14 13:31:26 +01:00
4e5a4c645d chore: remove Playwright installation steps from CI workflow
Some checks failed
CI / lint (push) Successful in 15s
CI / test (push) Failing after 1m2s
CI / build (push) Has been skipped
CI / deploy (push) Has been skipped
2025-11-14 13:26:33 +01:00
e9678b6736 chore: remove CI workflow file and update test files for improved structure and functionality
Some checks failed
CI / lint (push) Successful in 15s
CI / test (push) Failing after 16s
CI / build (push) Has been skipped
CI / deploy (push) Has been skipped
2025-11-14 13:25:02 +01:00
e5e346b26a Update templates/dashboard.html
Some checks failed
CI / build (push) Has been skipped
CI / test (push) Failing after 17s
CI / deploy (push) Has been skipped
CI / lint (push) Successful in 16s
2025-11-14 13:11:08 +01:00
b0e623d68e fix(tests): use secure token generation for access token in navigation client
Some checks failed
CI / lint (push) Successful in 15s
CI / build (push) Has been skipped
CI / test (push) Failing after 18s
CI / deploy (push) Has been skipped
2025-11-14 13:08:09 +01:00
30dbc13fae fix(init_db): correct SQL syntax for navigation link insertion
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped
2025-11-14 12:51:48 +01:00
31b9a1058a refactor: remove unused imports and streamline code in calculations and navigation services
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 14s
CI / deploy (push) Has been skipped
2025-11-14 12:28:48 +01:00
bcd993d57c feat(changelog): document completion of UI alignment initiative and style consolidation
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped
2025-11-13 22:34:31 +01:00
1262a4a63f Refactor CSS styles and introduce theme variables
- Removed redundant CSS rules and consolidated styles across dashboard, forms, imports, projects, and scenarios.
- Introduced new color variables in theme-default.css for better maintainability and consistency.
- Updated existing styles to utilize new color variables, enhancing the overall design.
- Improved responsiveness and layout of various components, including tables and cards.
- Ensured consistent styling for buttons, links, and headers across the application.
2025-11-13 22:30:58 +01:00
fb6816de00 Add form styles and update button classes for consistency
- Introduced a new CSS file for form styles (forms.css) to enhance form layout and design.
- Removed deprecated button styles from imports.css and updated button classes across templates to use the new utility classes.
- Updated various templates to reflect the new button styles, ensuring a consistent look and feel throughout the application.
- Refactored form-related styles in main.css and removed redundant styles from projects.css and scenarios.css.
- Ensured responsive design adjustments for form actions in smaller viewports.
2025-11-13 21:18:32 +01:00
edf86a5447 Update templates/dashboard.html
Some checks failed
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-12 11:22:33 +01:00
48 changed files with 1606 additions and 1542 deletions

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
* text=auto
Dockerfile text eol=lf

View File

@@ -0,0 +1,232 @@
name: CI - Build
on:
workflow_call:
workflow_dispatch:
jobs:
build:
outputs:
allow_push: ${{ steps.meta.outputs.allow_push }}
ref_name: ${{ steps.meta.outputs.ref_name }}
event_name: ${{ steps.meta.outputs.event_name }}
sha: ${{ steps.meta.outputs.sha }}
runs-on: ubuntu-latest
env:
DEFAULT_BRANCH: main
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_CONTAINER_NAME: calminer
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Collect workflow metadata
id: meta
shell: bash
env:
DEFAULT_BRANCH: ${{ env.DEFAULT_BRANCH }}
run: |
git_ref="${GITEA_REF:-${GITHUB_REF:-}}"
ref_name="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
if [ -z "$ref_name" ] && [ -n "$git_ref" ]; then
ref_name="${git_ref##*/}"
fi
event_name="${GITEA_EVENT_NAME:-${GITHUB_EVENT_NAME:-}}"
sha="${GITEA_SHA:-${GITHUB_SHA:-}}"
if [ -z "$sha" ]; then
sha="$(git rev-parse HEAD)"
fi
if [ "$ref_name" = "${DEFAULT_BRANCH:-main}" ] && [ "$event_name" != "pull_request" ]; then
echo "allow_push=true" >> "$GITHUB_OUTPUT"
else
echo "allow_push=false" >> "$GITHUB_OUTPUT"
fi
echo "ref_name=$ref_name" >> "$GITHUB_OUTPUT"
echo "event_name=$event_name" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- name: Validate registry configuration
shell: bash
run: |
set -euo pipefail
if [ -z "${REGISTRY_URL}" ]; then
echo "::error::REGISTRY_URL secret not configured. Configure it with your Gitea container registry host." >&2
exit 1
fi
server_url="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
server_host="${server_url#http://}"
server_host="${server_host#https://}"
server_host="${server_host%%/*}"
server_host="${server_host%%:*}"
registry_host="${REGISTRY_URL#http://}"
registry_host="${registry_host#https://}"
registry_host="${registry_host%%/*}"
registry_host="${registry_host%%:*}"
if [ -n "${server_host}" ] && ! printf '%s' "${registry_host}" | grep -qi "${server_host}"; then
echo "::warning::REGISTRY_URL (${REGISTRY_URL}) does not match current Gitea host (${server_host}). Ensure this registry endpoint is managed by Gitea." >&2
fi
registry_repository="${registry_host}/allucanget/${REGISTRY_CONTAINER_NAME}"
echo "REGISTRY_HOST=${registry_host}" >> "$GITHUB_ENV"
echo "REGISTRY_REPOSITORY=${registry_repository}" >> "$GITHUB_ENV"
- name: Set up QEMU and Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to gitea registry
if: ${{ steps.meta.outputs.allow_push == 'true' }}
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ env.REGISTRY_USERNAME }}
password: ${{ env.REGISTRY_PASSWORD }}
- name: Build image
id: build-image
env:
REGISTRY_REPOSITORY: ${{ env.REGISTRY_REPOSITORY }}
REGISTRY_CONTAINER_NAME: ${{ env.REGISTRY_CONTAINER_NAME }}
SHA_TAG: ${{ steps.meta.outputs.sha }}
PUSH_IMAGE: ${{ steps.meta.outputs.allow_push == 'true' && env.REGISTRY_HOST != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
run: |
set -eo pipefail
LOG_FILE=build.log
if [ "${PUSH_IMAGE}" = "true" ]; then
docker buildx build \
--load \
--tag "${REGISTRY_REPOSITORY}:latest" \
--tag "${REGISTRY_REPOSITORY}:${SHA_TAG}" \
--file Dockerfile \
. 2>&1 | tee "${LOG_FILE}"
else
docker buildx build \
--load \
--tag "${REGISTRY_CONTAINER_NAME}:ci" \
--file Dockerfile \
. 2>&1 | tee "${LOG_FILE}"
fi
- name: Push image
if: ${{ steps.meta.outputs.allow_push == 'true' }}
env:
REGISTRY_REPOSITORY: ${{ env.REGISTRY_REPOSITORY }}
SHA_TAG: ${{ steps.meta.outputs.sha }}
run: |
set -euo pipefail
if [ -z "${REGISTRY_REPOSITORY}" ]; then
echo "::error::REGISTRY_REPOSITORY not defined; cannot push image" >&2
exit 1
fi
docker push "${REGISTRY_REPOSITORY}:${SHA_TAG}"
docker push "${REGISTRY_REPOSITORY}:latest"
- name: Upload docker build logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: docker-build-logs
path: build.log
deploy:
needs: build
if: needs.build.outputs.allow_push == 'true'
runs-on: ubuntu-latest
env:
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
REGISTRY_CONTAINER_NAME: calminer
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
STAGING_KUBE_CONFIG: ${{ secrets.STAGING_KUBE_CONFIG }}
PROD_KUBE_CONFIG: ${{ secrets.PROD_KUBE_CONFIG }}
K8S_DEPLOY_ENABLED: ${{ secrets.K8S_DEPLOY_ENABLED }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve registry repository
run: |
set -euo pipefail
if [ -z "${REGISTRY_URL}" ]; then
echo "::error::REGISTRY_URL secret not configured. Configure it with your Gitea container registry host." >&2
exit 1
fi
registry_host="${REGISTRY_URL#http://}"
registry_host="${registry_host#https://}"
registry_host="${registry_host%%/*}"
registry_host="${registry_host%%:*}"
registry_repository="${registry_host}/allucanget/${REGISTRY_CONTAINER_NAME}"
echo "REGISTRY_HOST=${registry_host}" >> "$GITHUB_ENV"
echo "REGISTRY_REPOSITORY=${registry_repository}" >> "$GITHUB_ENV"
- name: Report Kubernetes deployment toggle
run: |
set -euo pipefail
enabled="${K8S_DEPLOY_ENABLED:-}"
if [ "${enabled}" = "true" ]; then
echo "Kubernetes deployment is enabled for this run."
else
echo "::notice::Kubernetes deployment steps are disabled (set secrets.K8S_DEPLOY_ENABLED to 'true' to enable)."
fi
- name: Capture commit metadata
id: commit_meta
run: |
set -euo pipefail
message="$(git log -1 --pretty=%B | tr '\n' ' ')"
echo "message=$message" >> "$GITHUB_OUTPUT"
- name: Set up kubectl for staging
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy staging]')
uses: azure/k8s-set-context@v3
with:
method: kubeconfig
kubeconfig: ${{ env.STAGING_KUBE_CONFIG }}
- name: Set up kubectl for production
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy production]')
uses: azure/k8s-set-context@v3
with:
method: kubeconfig
kubeconfig: ${{ env.PROD_KUBE_CONFIG }}
- name: Deploy to staging
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy staging]')
run: |
kubectl set image deployment/calminer-app calminer=${REGISTRY_REPOSITORY}:latest
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secret.yaml
kubectl rollout status deployment/calminer-app
- name: Collect staging deployment logs
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy staging]')
run: |
mkdir -p logs/deployment/staging
kubectl get pods -o wide > logs/deployment/staging/pods.txt
kubectl get deployment calminer-app -o yaml > logs/deployment/staging/deployment.yaml
kubectl logs deployment/calminer-app --all-containers=true --tail=500 > logs/deployment/staging/calminer-app.log
- name: Deploy to production
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy production]')
run: |
kubectl set image deployment/calminer-app calminer=${REGISTRY_REPOSITORY}:latest
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secret.yaml
kubectl rollout status deployment/calminer-app
- name: Collect production deployment logs
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy production]')
run: |
mkdir -p logs/deployment/production
kubectl get pods -o wide > logs/deployment/production/pods.txt
kubectl get deployment calminer-app -o yaml > logs/deployment/production/deployment.yaml
kubectl logs deployment/calminer-app --all-containers=true --tail=500 > logs/deployment/production/calminer-app.log
- name: Upload deployment logs
if: always()
uses: actions/upload-artifact@v4
with:
name: deployment-logs
path: logs/deployment
if-no-files-found: ignore

View File

@@ -0,0 +1,44 @@
name: CI - Lint
on:
workflow_call:
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
env:
APT_CACHER_NG: http://192.168.88.14:3142
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Configure apt proxy
run: |
if [ -n "${APT_CACHER_NG}" ]; then
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
fi
- name: Install system packages
run: |
apt-get update
apt-get install -y build-essential libpq-dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run Ruff
run: ruff check .
- name: Run Black
run: black --check .
- name: Run Bandit
run: bandit -c pyproject.toml -r tests

View File

@@ -0,0 +1,73 @@
name: CI - Test
on:
workflow_call:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
env:
APT_CACHER_NG: http://192.168.88.14:3142
DB_DRIVER: postgresql+psycopg2
DB_HOST: 192.168.88.35
DB_NAME: calminer_test
DB_USER: calminer
DB_PASSWORD: calminer_password
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: ${{ env.DB_USER }}
POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }}
POSTGRES_DB: ${{ env.DB_NAME }}
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Configure apt proxy
run: |
if [ -n "${APT_CACHER_NG}" ]; then
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
fi
- name: Install system packages
run: |
apt-get update
apt-get install -y build-essential libpq-dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run tests
env:
DATABASE_DRIVER: ${{ env.DB_DRIVER }}
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USER: ${{ env.DB_USER }}
DATABASE_PASSWORD: ${{ env.DB_PASSWORD }}
DATABASE_NAME: ${{ env.DB_NAME }}
run: |
pytest --cov=. --cov-report=term-missing --cov-report=xml --cov-fail-under=80 --junitxml=pytest-report.xml
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: test-artifacts
path: |
coverage.xml
pytest-report.xml

30
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,30 @@
name: CI
on:
push:
branches:
- main
- develop
- v2
pull_request:
branches:
- main
- develop
workflow_dispatch:
jobs:
lint:
uses: ./.gitea/workflows/ci-lint.yml
secrets: inherit
test:
needs: lint
uses: ./.gitea/workflows/ci-test.yml
secrets: inherit
build:
needs:
- lint
- test
uses: ./.gitea/workflows/ci-build.yml
secrets: inherit

View File

@@ -1,212 +0,0 @@
name: CI
on:
push:
branches: [main, develop, v2]
pull_request:
branches: [main, develop]
jobs:
lint:
runs-on: ubuntu-latest
env:
APT_CACHER_NG: http://192.168.88.14:3142
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
# - name: Cache pip dependencies
# uses: actions/cache@v4
# with:
# path: /root/.cache/pip
# key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt', 'pyproject.toml') }}
# restore-keys: |
# ${{ runner.os }}-pip-
- name: Configure apt proxy
run: |
if [ -n \"${APT_CACHER_NG}\" ]; then
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
fi
- name: Install system packages
run: |
apt-get update
apt-get install -y build-essential libpq-dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run Ruff
run: ruff check .
- name: Run Black
run: black --check .
- name: Run bandit
run: bandit -c pyproject.toml -r tests
test:
runs-on: ubuntu-latest
needs: lint
env:
APT_CACHER_NG: http://192.168.88.14:3142
DB_DRIVER: postgresql+psycopg2
DB_HOST: 192.168.88.35
DB_NAME: calminer_test
DB_USER: calminer
DB_PASSWORD: calminer_password
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: ${{ env.DB_USER }}
POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }}
POSTGRES_DB: ${{ env.DB_NAME }}
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Get pip cache dir
id: pip-cache
run: |
echo \"path=$(pip cache dir)\" >> $GITEA_OUTPUT
echo \"Pip cache dir: $(pip cache dir)\"
# - name: Cache pip dependencies
# uses: actions/cache@v4
# with:
# path: /root/.cache/pip
# key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt', 'pyproject.toml') }}
# restore-keys: |
# ${{ runner.os }}-pip-
- name: Configure apt proxy
run: |
if [ -n \"${APT_CACHER_NG}\" ]; then
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
fi
- name: Install system packages
run: |
apt-get update
apt-get install -y build-essential libpq-dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run tests
env:
DATABASE_DRIVER: ${{ env.DB_DRIVER }}
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USER: ${{ env.DB_USER }}
DATABASE_PASSWORD: ${{ env.DB_PASSWORD }}
DATABASE_NAME: ${{ env.DB_NAME }}
run: |
pytest --cov=. --cov-report=term-missing --cov-report=xml --junitxml=pytest-report.xml
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: test-artifacts
path: |
coverage.xml
pytest-report.xml
build:
runs-on: ubuntu-latest
needs:
- lint
- test
env:
DEFAULT_BRANCH: main
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_CONTAINER_NAME: calminer
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Collect workflow metadata
id: meta
shell: bash
run: |
ref_name=\"${GITHUB_REF_NAME:-${GITHUB_REF##*/}}\"
event_name=\"${GITHUB_EVENT_NAME:-}\"
sha=\"${GITHUB_SHA:-}\"
if [ \"$ref_name\" = \"${DEFAULT_BRANCH:-main}\" ]; then
echo \"on_default=true\" >> \"$GITHUB_OUTPUT\"
else
echo \"on_default=false\" >> \"$GITHUB_OUTPUT\"
fi
echo \"ref_name=$ref_name\" >> \"$GITHUB_OUTPUT\"
echo \"event_name=$event_name\" >> \"$GITHUB_OUTPUT\"
echo \"sha=$sha\" >> \"$GITHUB_OUTPUT\"
- name: Set up QEMU and Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to gitea registry
if: ${{ steps.meta.outputs.on_default == 'true' }}
uses: docker/login-action@v3
continue-on-error: true
with:
registry: ${{ env.REGISTRY_URL }}
username: ${{ env.REGISTRY_USERNAME }}
password: ${{ env.REGISTRY_PASSWORD }}
- name: Build image
id: build-image
env:
REGISTRY_URL: ${{ env.REGISTRY_URL }}
REGISTRY_CONTAINER_NAME: ${{ env.REGISTRY_CONTAINER_NAME }}
SHA_TAG: ${{ steps.meta.outputs.sha }}
PUSH_IMAGE: ${{ steps.meta.outputs.on_default == 'true' && steps.meta.outputs.event_name != 'pull_request' && env.REGISTRY_URL != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
run: |
set -eo pipefail
LOG_FILE=build.log
if [ \"${PUSH_IMAGE}\" = \"true\" ]; then
docker buildx build \
--push \
--tag \"${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:latest\" \
--tag \"${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:${SHA_TAG}\" \
--file Dockerfile \
. 2>&1 | tee \"${LOG_FILE}\"
else
docker buildx build \
--load \
--tag \"${REGISTRY_CONTAINER_NAME}:ci\" \
--file Dockerfile \
. 2>&1 | tee \"${LOG_FILE}\"
fi
- name: Upload docker build logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: docker-build-logs
path: build.log

View File

@@ -1,298 +0,0 @@
name: CI
on:
push:
branches: [main, develop, v2]
pull_request:
branches: [main, develop]
jobs:
lint:
runs-on: ubuntu-latest
env:
APT_CACHER_NG: http://192.168.88.14:3142
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
# - name: Get pip cache dir
# id: pip-cache
# run: |
# echo "path=$(pip cache dir)" >> $GITEA_OUTPUT
# echo "Pip cache dir: $(pip cache dir)"
# - name: Cache pip dependencies
# uses: actions/cache@v4
# with:
# path: ${{ steps.pip-cache.outputs.path }}
# key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt', 'pyproject.toml') }}
# restore-keys: |
# ${{ runner.os }}-pip-
- name: Configure apt proxy
run: |
if [ -n "${APT_CACHER_NG}" ]; then
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
fi
- name: Install system packages
run: |
apt-get update
apt-get install -y build-essential libpq-dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run Ruff
run: ruff check .
- name: Run Black
run: black --check .
- name: Run Bandit
run: bandit -c pyproject.toml -r tests
test:
runs-on: ubuntu-latest
needs: lint
env:
APT_CACHER_NG: http://192.168.88.14:3142
DB_DRIVER: postgresql+psycopg2
DB_HOST: 192.168.88.35
DB_NAME: calminer_test
DB_USER: calminer
DB_PASSWORD: calminer_password
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: ${{ env.DB_USER }}
POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }}
POSTGRES_DB: ${{ env.DB_NAME }}
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
# - name: Get pip cache dir
# id: pip-cache
# run: |
# echo "path=$(pip cache dir)" >> $GITEA_OUTPUT
# echo "Pip cache dir: $(pip cache dir)"
# - name: Cache pip dependencies
# uses: actions/cache@v4
# with:
# path: ${{ steps.pip-cache.outputs.path }}
# key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt', 'pyproject.toml') }}
# restore-keys: |
# ${{ runner.os }}-pip-
- name: Configure apt proxy
run: |
if [ -n "${APT_CACHER_NG}" ]; then
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
fi
- name: Install system packages
run: |
apt-get update
apt-get install -y build-essential libpq-dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Install Playwright system dependencies
run: playwright install-deps
- name: Install Playwright browsers
run: playwright install
- name: Run tests
env:
DATABASE_DRIVER: ${{ env.DB_DRIVER }}
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USER: ${{ env.DB_USER }}
DATABASE_PASSWORD: ${{ env.DB_PASSWORD }}
DATABASE_NAME: ${{ env.DB_NAME }}
run: |
pytest --cov=. --cov-report=term-missing --cov-report=xml --cov-fail-under=80 --junitxml=pytest-report.xml
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: test-artifacts
path: |
coverage.xml
pytest-report.xml
build:
runs-on: ubuntu-latest
needs:
- lint
- test
env:
DEFAULT_BRANCH: main
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_CONTAINER_NAME: calminer
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Collect workflow metadata
id: meta
shell: bash
run: |
ref_name="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
event_name="${GITHUB_EVENT_NAME:-}"
sha="${GITHUB_SHA:-}"
if [ "$ref_name" = "${DEFAULT_BRANCH:-main}" ]; then
echo "on_default=true" >> "$GITHUB_OUTPUT"
else
echo "on_default=false" >> "$GITHUB_OUTPUT"
fi
echo "ref_name=$ref_name" >> "$GITHUB_OUTPUT"
echo "event_name=$event_name" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- name: Set up QEMU and Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to gitea registry
if: ${{ steps.meta.outputs.on_default == 'true' }}
uses: docker/login-action@v3
continue-on-error: true
with:
registry: ${{ env.REGISTRY_URL }}
username: ${{ env.REGISTRY_USERNAME }}
password: ${{ env.REGISTRY_PASSWORD }}
- name: Build image
id: build-image
env:
REGISTRY_URL: ${{ env.REGISTRY_URL }}
REGISTRY_CONTAINER_NAME: ${{ env.REGISTRY_CONTAINER_NAME }}
SHA_TAG: ${{ steps.meta.outputs.sha }}
PUSH_IMAGE: ${{ steps.meta.outputs.on_default == 'true' && steps.meta.outputs.event_name != 'pull_request' && env.REGISTRY_URL != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
run: |
set -eo pipefail
LOG_FILE=build.log
if [ "${PUSH_IMAGE}" = "true" ]; then
docker buildx build \
--push \
--tag "${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:latest" \
--tag "${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:${SHA_TAG}" \
--file Dockerfile \
. 2>&1 | tee "${LOG_FILE}"
else
docker buildx build \
--load \
--tag "${REGISTRY_CONTAINER_NAME}:ci" \
--file Dockerfile \
. 2>&1 | tee "${LOG_FILE}"
fi
- name: Upload docker build logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: docker-build-logs
path: build.log
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
REGISTRY_CONTAINER_NAME: calminer
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
STAGING_KUBE_CONFIG: ${{ secrets.STAGING_KUBE_CONFIG }}
PROD_KUBE_CONFIG: ${{ secrets.PROD_KUBE_CONFIG }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up kubectl for staging
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy staging]')
uses: azure/k8s-set-context@v3
with:
method: kubeconfig
kubeconfig: ${{ env.STAGING_KUBE_CONFIG }}
- name: Set up kubectl for production
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy production]')
uses: azure/k8s-set-context@v3
with:
method: kubeconfig
kubeconfig: ${{ env.PROD_KUBE_CONFIG }}
- name: Deploy to staging
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy staging]')
run: |
# Update image in deployment
kubectl set image deployment/calminer-app calminer=${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:latest
# Apply any config changes
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secret.yaml
# Wait for rollout
kubectl rollout status deployment/calminer-app
- name: Collect staging deployment logs
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy staging]')
run: |
mkdir -p logs/deployment/staging
kubectl get pods -o wide > logs/deployment/staging/pods.txt
kubectl get deployment calminer-app -o yaml > logs/deployment/staging/deployment.yaml
kubectl logs deployment/calminer-app --all-containers=true --tail=500 > logs/deployment/staging/calminer-app.log
- name: Deploy to production
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy production]')
run: |
# Update image in deployment
kubectl set image deployment/calminer-app calminer=${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:latest
# Apply any config changes
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secret.yaml
# Wait for rollout
kubectl rollout status deployment/calminer-app
- name: Collect production deployment logs
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy production]')
run: |
mkdir -p logs/deployment/production
kubectl get pods -o wide > logs/deployment/production/pods.txt
kubectl get deployment calminer-app -o yaml > logs/deployment/production/deployment.yaml
kubectl logs deployment/calminer-app --all-containers=true --tail=500 > logs/deployment/production/calminer-app.log
- name: Upload deployment logs
if: always()
uses: actions/upload-artifact@v4
with:
name: deployment-logs
path: logs/deployment
if-no-files-found: ignore

View File

@@ -0,0 +1,78 @@
name: Deploy - Coolify
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
env:
COOLIFY_BASE_URL: ${{ secrets.COOLIFY_BASE_URL }}
COOLIFY_API_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }}
COOLIFY_APPLICATION_ID: ${{ secrets.COOLIFY_APPLICATION_ID }}
COOLIFY_DEPLOY_ENV: ${{ secrets.COOLIFY_DEPLOY_ENV }}
DOCKER_COMPOSE_PATH: docker-compose.prod.yml
ENV_FILE_PATH: deploy/.env
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Prepare compose bundle
run: |
set -euo pipefail
mkdir -p deploy
cp "$DOCKER_COMPOSE_PATH" deploy/docker-compose.yml
if [ -n "$COOLIFY_DEPLOY_ENV" ]; then
printf '%s\n' "$COOLIFY_DEPLOY_ENV" > "$ENV_FILE_PATH"
elif [ ! -f "$ENV_FILE_PATH" ]; then
echo "::error::COOLIFY_DEPLOY_ENV secret not configured and deploy/.env missing" >&2
exit 1
fi
- name: Validate Coolify secrets
run: |
set -euo pipefail
missing=0
for var in COOLIFY_BASE_URL COOLIFY_API_TOKEN COOLIFY_APPLICATION_ID; do
if [ -z "${!var}" ]; then
echo "::error::Missing required secret: $var"
missing=1
fi
done
if [ "$missing" -eq 1 ]; then
exit 1
fi
- name: Trigger deployment via Coolify API
run: |
set -euo pipefail
api_url="$COOLIFY_BASE_URL/api/v1/deploy"
payload=$(jq -n --arg uuid "$COOLIFY_APPLICATION_ID" '{ uuid: $uuid }')
response=$(curl -sS -w '\n%{http_code}' \
-X POST "$api_url" \
-H "Authorization: Bearer $COOLIFY_API_TOKEN" \
-H "Content-Type: application/json" \
-d "$payload")
body=$(echo "$response" | head -n -1)
status=$(echo "$response" | tail -n1)
echo "Deploy response status: $status"
echo "$body"
printf '%s' "$body" > deploy/coolify-response.json
if [ "$status" -ge 400 ]; then
echo "::error::Deployment request failed"
exit 1
fi
- name: Upload deployment bundle
if: always()
uses: actions/upload-artifact@v3
with:
name: coolify-deploy-bundle
path: |
deploy/docker-compose.yml
deploy/.env
deploy/coolify-response.json
if-no-files-found: warn

4
.gitignore vendored
View File

@@ -54,3 +54,7 @@ local*.db
# Act runner files
.runner
# Devcontainer files
.devcontainer/devcontainer.json
.devcontainer/docker-compose.yml

View File

@@ -41,8 +41,25 @@ if url:
finally:
sock.close()
PY
APT_PROXY_CONFIG=/etc/apt/apt.conf.d/01proxy
apt_update_with_fallback() {
if ! apt-get update; then
rm -f "$APT_PROXY_CONFIG"
apt-get update
apt-get install -y --no-install-recommends build-essential gcc libpq-dev
fi
}
apt_install_with_fallback() {
if ! apt-get install -y --no-install-recommends "$@"; then
rm -f "$APT_PROXY_CONFIG"
apt-get update
apt-get install -y --no-install-recommends "$@"
fi
}
apt_update_with_fallback
apt_install_with_fallback build-essential gcc libpq-dev
pip install --upgrade pip
pip wheel --no-deps --wheel-dir /wheels -r requirements.txt
apt-get purge -y --auto-remove build-essential gcc
@@ -88,8 +105,25 @@ if url:
finally:
sock.close()
PY
APT_PROXY_CONFIG=/etc/apt/apt.conf.d/01proxy
apt_update_with_fallback() {
if ! apt-get update; then
rm -f "$APT_PROXY_CONFIG"
apt-get update
apt-get install -y --no-install-recommends libpq5
fi
}
apt_install_with_fallback() {
if ! apt-get install -y --no-install-recommends "$@"; then
rm -f "$APT_PROXY_CONFIG"
apt-get update
apt-get install -y --no-install-recommends "$@"
fi
}
apt_update_with_fallback
apt_install_with_fallback libpq5
rm -rf /var/lib/apt/lists/*
EOF

View File

@@ -1,111 +1,124 @@
# Changelog
## 2025-11-15
- Fixed dev container setup by reviewing logs, identifying mount errors, implementing fixes, and validating the configuration.
## 2025-11-14
- Completed Coolify deployment automation with workflow and documentation.
- Improved build workflow for registry authentication and tagging.
- Updated production compose and added deployment guidance.
- Added optional Kubernetes deployment toggle.
## 2025-11-13
- Refactored the architecture data model docs by turning `calminer-docs/architecture/08_concepts/02_data_model.md` into a concise overview that links to new detail pages covering SQLAlchemy models, navigation metadata, enumerations, Pydantic schemas, and monitoring tables.
- Nested the calculator navigation under Projects by updating `scripts/init_db.py` seeds, teaching `services/navigation.py` to resolve scenario-scoped hrefs for profitability/opex/capex, and extending sidebar coverage through `tests/integration/test_navigation_sidebar_calculations.py` plus `tests/services/test_navigation_service.py` to validate admin/viewer visibility and contextual URL generation.
- Added navigation sidebar integration coverage by extending `tests/conftest.py` with role-switching headers, seeding admin/viewer test users, and adding `tests/integration/test_navigation_sidebar.py` to assert ordered link rendering for admins, viewer filtering of admin-only entries, and anonymous rejection of the endpoint.
- Finalised the financial data import/export templates by inventorying required fields, defining CSV column specs with validation rules, drafting Excel workbook layouts, documenting end-user workflows in `calminer-docs/userguide/data_import_export.md`, and recording stakeholder review steps alongside updated TODO/DONE tracking.
- Scoped profitability calculator UI under the scenario hierarchy by adding `/calculations/projects/{project_id}/scenarios/{scenario_id}/profitability` GET/POST handlers, updating scenario templates and sidebar navigation to link to the new route, and extending `tests/test_project_scenario_routes.py` with coverage for the scenario path plus legacy redirect behaviour (module run: 14 passed).
- Extended scenario frontend regression coverage by updating `tests/test_project_scenario_routes.py` to assert project/scenario breadcrumbs and calculator navigation, normalising escaped URLs, and re-running the module tests (13 passing).
- Cleared FastAPI and Pydantic deprecation warnings by migrating `scripts/init_db.py` to `@field_validator`, replacing the `main.py` startup hook with a lifespan handler, auditing template response call signatures, confirming HTTP 422 constant usage, and re-running the full pytest suite to ensure a clean warning slate.
- Delivered the capex planner end-to-end: added scaffolded UI in `templates/scenarios/capex.html`, wired GET/POST handlers through `routes/calculations.py`, implemented calculation logic plus snapshot persistence in `services/calculations.py` and `models/capex_snapshot.py`, updated navigation links, and introduced unit tests in `tests/services/test_calculations_capex.py`.
- Updated UI navigation to surface the opex planner by adding the sidebar link in `templates/partials/sidebar_nav.html`, wiring a scenario detail action in `templates/scenarios/detail.html`.
- Completed manual validation of the Capex Planner UI flows (sidebar entry, scenario deep link, validation errors, successful calculation) with results captured in `manual_tests/capex.md`, documented snapshot verification steps, and noted the optional JSON client check for future follow-up.
- Added opex calculation unit tests in `tests/services/test_calculations_opex.py` covering success metrics, currency validation, frequency enforcement, and evaluation horizon extension.
- Documented the Opex Planner workflow in `calminer-docs/userguide/opex_planner.md`, linked it from the user guide index, extended `calminer-docs/architecture/08_concepts/02_data_model.md` with snapshot coverage, and captured the completion in `.github/instructions/DONE.md`.
- Implemented opex integration coverage in `tests/integration/test_opex_calculations.py`, exercising HTML and JSON flows, verifying snapshot persistence, and asserting currency mismatch handling for form and API submissions.
- Executed the full pytest suite with coverage (211 tests) to confirm no regressions or warnings after the opex documentation updates.
- Completed the navigation sidebar API migration by finalising the database-backed service, refactoring `templates/partials/sidebar_nav.html` to consume the endpoint, hydrating via `static/js/navigation_sidebar.js`, and updating HTML route dependencies (`routes/projects.py`, `routes/scenarios.py`, `routes/reports.py`, `routes/imports.py`, `routes/calculations.py`) to use redirect-aware guards so anonymous visitors receive login redirects instead of JSON errors (manual verification via curl across projects, scenarios, reports, and calculations pages).
- Aligned UI styles and ensured accessibility.
- Restructured navigation under project-scenario-calculation hierarchy.
- Reorganized documentation for better structure.
- Refactored navigation sidebar with database-driven data.
- Migrated sidebar rendering to API endpoint.
- Created templates for data import and export.
- Updated relationships for projects, scenarios, and profitability.
- Enhanced scenario frontend templates with project context.
- Scoped profitability calculator to scenario level.
- Added navigation links for opex planner.
- Documented opex planner features.
- Integrated opex calculations with persistence and tests.
- Implemented capex calculations end-to-end.
- Added basic profitability calculations.
- Developed reporting endpoints and templates.
- Integrated charting for visualizations.
- Performed manual testing of capex planner.
- Added unit tests for opex service.
- Added integration tests for opex.
## 2025-11-12
- Fixed critical 500 error in reporting dashboard by correcting route reference in reporting.html template - changed 'reports.project_list_page' to 'projects.project_list_page' to resolve NoMatchFound error when accessing /ui/reporting.
- Completed navigation validation by inventorying all sidebar navigation links, identifying missing routes for simulations, reporting, settings, themes, and currencies, created new UI routes in routes/ui.py with proper authentication guards, built corresponding templates (simulations.html, reporting.html, settings.html, theme_settings.html, currencies.html), registered the UI router in main.py, updated sidebar navigation to use route names instead of hardcoded URLs, and enhanced navigation.js to use dynamic URL resolution for proper route handling.
- Fixed critical template rendering error in sidebar_nav.html where URL objects from `request.url_for()` were being used with string methods, causing TypeError. Added `|string` filters to convert URL objects to strings for proper template rendering.
- Integrated Plotly charting for interactive visualizations in reporting templates, added chart generation methods to ReportingService (`generate_npv_comparison_chart`, `generate_distribution_histogram`), updated project summary and scenario distribution contexts to include chart JSON data, enhanced templates with chart containers and JavaScript rendering, added chart-container CSS styling, and validated all reporting tests pass.
- Completed local run verification: started application with `uvicorn main:app --reload` without errors, verified authenticated routes (/login, /, /projects/ui, /projects) load correctly with seeded data, and summarized findings for deployment pipeline readiness.
- Fixed docker-compose.override.yml command array to remove duplicate "uvicorn" entry, enabling successful container startup with uvicorn reload in development mode.
- Completed deployment pipeline verification: built Docker image without errors, validated docker-compose configuration, deployed locally with docker-compose (app and postgres containers started successfully), and confirmed application startup logs showing database bootstrap and seeded data initialization.
- Completed documentation of current data models: updated `calminer-docs/architecture/08_concepts/02_data_model.md` with comprehensive SQLAlchemy model schemas, enumerations, Pydantic API schemas, and analysis of discrepancies between models and schemas.
- Switched `models/performance_metric.py` to reuse the shared declarative base from `config.database`, clearing the SQLAlchemy 2.0 `declarative_base` deprecation warning and verifying repository tests still pass.
- Replaced the Alembic migration workflow with the idempotent Pydantic-backed initializer (`scripts/init_db.py`), added a guarded reset utility (`scripts/reset_db.py`), removed migration artifacts/tooling (Alembic directory, config, Docker entrypoint), refreshed the container entrypoint to invoke `uvicorn` directly, and updated installation/architecture docs plus the README to direct developers to the new seeding/reset flow.
- Eliminated Bandit hardcoded-secret findings by replacing literal JWT tokens and passwords across auth/security tests with randomized helpers drawn from `tests/utils/security.py`, ensuring fixtures still assert expected behaviours.
- Centralized Bandit configuration in `pyproject.toml`, reran `bandit -c pyproject.toml -r calminer tests`, and verified the scan now reports zero issues.
- Diagnosed admin bootstrap failure caused by legacy `roles` schema, added Alembic migration `20251112_00_add_roles_metadata_columns.py` to backfill `display_name`, `description`, `created_at`, and `updated_at`, and verified the migration via full pytest run in the activated `.venv`.
- Resolved Ruff E402 warnings by moving module docstrings ahead of `from __future__ import annotations` across currency and pricing service modules, dropped the unused `HTTPException` import in `monitoring/__init__.py`, and confirmed a clean `ruff check .` run.
- Enhanced the deploy job in `.gitea/workflows/cicache.yml` to capture Kubernetes pod, deployment, and container logs into `/logs/deployment/` for staging/production rollouts and publish them via a `deployment-logs` artifact, updating CI/CD documentation with retrieval instructions.
- Fixed CI dashboard template lookup failures by renaming `templates/Dashboard.html` to `templates/dashboard.html` and verifying `tests/test_dashboard_route.py` locally to ensure TemplateNotFound no longer occurs on case-sensitive filesystems.
- Implemented SQLite support as primary local database with environment-driven backend switching (`CALMINER_USE_SQLITE=true`), updated `scripts/init_db.py` for database-agnostic DDL generation (PostgreSQL enums vs SQLite CHECK constraints), tested compatibility with both backends, and verified application startup and seeded data initialization work seamlessly across SQLite and PostgreSQL.
- Fixed reporting dashboard error by correcting route reference.
- Completed navigation validation by adding missing routes and templates for various pages.
- Fixed template rendering error with URL objects.
- Integrated charting for interactive visualizations.
- Verified local application startup and routes.
- Fixed docker-compose configuration.
- Verified deployment pipeline.
- Documented data models.
- Updated performance model to clear warnings.
- Replaced migration system with simpler initializer.
- Removed hardcoded secrets from tests.
- Centralized security scanning config.
- Fixed admin setup with migration.
- Resolved code style warnings.
- Enhanced deploy logging.
- Fixed CI template issue.
- Added SQLite database support.
## 2025-11-11
- Collapsed legacy Alembic revisions into `alembic/versions/00_initial.py`, removed superseded migration files, and verified the consolidated schema via SQLite upgrade and Postgres version stamping.
- Implemented base URL routing to redirect unauthenticated users to login and authenticated users to dashboard.
- Added comprehensive end-to-end tests for login flow, including redirects, session handling, and error messaging for invalid/inactive accounts.
- Updated header and footer templates to consistently use `logo_big.png` image instead of text logo, with appropriate CSS styling for sizing.
- Centralised ISO-4217 currency validation across scenarios, imports, and export filters (`models/scenario.py`, `routes/scenarios.py`, `schemas/scenario.py`, `schemas/imports.py`, `services/export_query.py`) so malformed codes are rejected consistently at every entry point.
- Updated scenario services and UI flows to surface friendly validation errors and added regression coverage for imports, exports, API creation, and lifecycle flows ensuring currencies are normalised end-to-end.
- Linked projects to their pricing settings by updating SQLAlchemy models, repositories, seeding utilities, and migrations, and added regression tests to cover the new association and default backfill.
- Bootstrapped database-stored pricing settings at application startup, aligned initial data seeding with the database-first metadata flow, and added tests covering pricing bootstrap creation, project assignment, and idempotency.
- Extended pricing configuration support to prefer persisted metadata via `dependencies.get_pricing_metadata`, added retrieval tests for project/default fallbacks, and refreshed docs (`calminer-docs/specifications/price_calculation.md`, `pricing_settings_data_model.md`) to describe the database-backed workflow and bootstrap behaviour.
- Added `services/financial.py` NPV, IRR, and payback helpers with robust cash-flow normalisation, convergence safeguards, and fractional period support, plus comprehensive pytest coverage exercising representative project scenarios and failure modes.
- Authored `calminer-docs/specifications/financial_metrics.md` capturing DCF assumptions, solver behaviours, and worked examples, and cross-linked the architecture concepts to the new reference for consistent navigation.
- Implemented `services/simulation.py` Monte Carlo engine with configurable distributions, summary aggregation, and reproducible RNG seeding, introduced regression tests in `tests/test_simulation.py`, and documented configuration/usage in `calminer-docs/specifications/monte_carlo_simulation.md` with architecture cross-links.
- Polished reporting HTML contexts by cleaning stray fragments in `routes/reports.py`, adding download action metadata for project and scenario pages, and generating scenario comparison download URLs with correctly serialised repeated `scenario_ids` parameters.
- Consolidated Alembic history into a single initial migration (`20251111_00_initial_schema.py`), removed superseded revision files, and ensured Alembic metadata still references the project metadata for clean bootstrap.
- Added `scripts/run_migrations.py` and a Docker entrypoint wrapper to run Alembic migrations before `uvicorn` starts, removed the fallback `Base.metadata.create_all` call, and updated `calminer-docs/admin/installation.md` so developers know how to apply migrations locally or via Docker.
- Configured pytest defaults to collect coverage (`--cov`) with an 80% fail-under gate, excluded entrypoint/reporting scaffolds from the calculation, updated contributor docs with the standard `pytest` command, and verified the suite now reports 83% coverage.
- Standardized color scheme and typography by moving alert styles to `main.css`, adding typography rules with CSS variables, updating auth templates for consistent button classes, and ensuring all templates use centralized color and spacing variables.
- Improved navigation flow by adding two big chevron buttons on top of the navigation sidebar to allow users to navigate to the previous and next page in the page navigation list, including JavaScript logic for determining current page and handling navigation.
- Established pytest-based unit and integration test suites with coverage thresholds, achieving 83% coverage across 181 tests, with configuration in pyproject.toml and documentation in CONTRIBUTING.md.
- Configured CI pipelines to run tests, linting, and security checks on each change, adding Bandit security scanning to the workflow and verifying execution on pushes and PRs to main/develop branches.
- Added deployment automation with Docker Compose for local development and Kubernetes manifests for production, ensuring environment parity and documenting processes in calminer-docs/admin/installation.md.
- Completed monitoring instrumentation by adding business metrics observation to project and scenario repository operations, and simulation performance tracking to Monte Carlo service with success/error status and duration metrics.
- Updated TODO list to reflect completed monitoring implementation tasks and validated changes with passing simulation tests.
- Implemented comprehensive performance monitoring for scalability (FR-006) with Prometheus metrics collection for HTTP requests, import/export operations, and general application metrics.
- Added database model for persistent metric storage with aggregation endpoints for KPIs like request latency, error rates, and throughput.
- Created FastAPI middleware for automatic request metric collection and background persistence to database.
- Extended monitoring router with performance metrics API endpoints and detailed health checks.
- Added Alembic migration for performance_metrics table and updated model imports.
- Completed concurrent interaction testing implementation, validating database transaction isolation under threading and establishing async testing framework for future concurrency enhancements.
- Implemented comprehensive deployment automation with Docker Compose configurations for development, staging, and production environments ensuring environment parity.
- Set up Kubernetes manifests with resource limits, health checks, and secrets management for production deployment.
- Configured CI/CD workflows for automated Docker image building, registry pushing, and Kubernetes deployment to staging/production environments.
- Documented deployment processes, environment configurations, and CI/CD workflows in project documentation.
- Validated deployment automation through Docker Compose configuration testing and CI/CD pipeline structure.
- Combined old migration files into one initial schema.
- Added base routing to redirect users to login or dashboard.
- Added end-to-end tests for login flow.
- Updated templates to use logo image consistently.
- Centralized currency validation across the app.
- Updated services to show friendly error messages.
- Linked projects to pricing settings.
- Bootstrapped pricing settings at startup.
- Extended pricing support with persisted data.
- Added financial helpers for NPV, IRR, payback.
- Documented financial metrics.
- Implemented Monte Carlo simulation engine.
- Cleaned up reporting contexts.
- Consolidated migration history.
- Added migration script and updated entrypoint.
- Configured test coverage.
- Standardized colors and typography.
- Improved navigation with chevron buttons.
- Established test suites with coverage.
- Configured CI pipelines for tests and security.
- Added deployment automation with Docker and Kubernetes.
- Completed monitoring instrumentation.
- Implemented performance monitoring.
- Added metric storage and endpoints.
- Created middleware for metrics.
- Extended monitoring router.
- Added migration for metrics table.
- Completed concurrent testing.
- Implemented deployment automation.
- Set up Kubernetes manifests.
- Configured CI/CD workflows.
- Documented deployment processes.
- Validated deployment setup.
## 2025-11-10
- Added dedicated pytest coverage for guard dependencies, exercising success plus failure paths (missing session, inactive user, missing roles, project/scenario access errors) via `tests/test_dependencies_guards.py`.
- Added integration tests in `tests/test_authorization_integration.py` verifying anonymous 401 responses, role-based 403s, and authorized project manager flows across API and UI endpoints.
- Implemented environment-driven admin bootstrap settings, wired the `bootstrap_admin` helper into FastAPI startup, added pytest coverage for creation/idempotency/reset logic, and documented operational guidance in the RBAC plan and security concept.
- Retired the legacy authentication RBAC implementation plan document after migrating its guidance into live documentation and synchronized the contributor instructions to reflect the removal.
- Completed the Authentication & RBAC checklist by shipping the new models, migrations, repositories, guard dependencies, and integration tests.
- Documented the project/scenario import/export field mapping and file format guidelines in `calminer-docs/requirements/FR-008.md`, and introduced `schemas/imports.py` with Pydantic models that normalise incoming CSV/Excel rows for projects and scenarios.
- Added `services/importers.py` to load CSV/XLSX files into the new import schemas, pulled in `openpyxl` for Excel support, and covered the parsing behaviour with `tests/test_import_parsing.py`.
- Expanded the import ingestion workflow with staging previews, transactional persistence commits, FastAPI preview/commit endpoints under `/imports`, and new API tests (`tests/test_import_ingestion.py`, `tests/test_import_api.py`) ensuring end-to-end coverage.
- Added persistent audit logging via `ImportExportLog`, structured log emission, Prometheus metrics instrumentation, `/metrics` endpoint exposure, and updated operator/deployment documentation to guide monitoring setup.
- Added tests for guard dependencies.
- Added integration tests for authorization.
- Implemented admin bootstrap settings.
- Retired old RBAC plan document.
- Completed authentication and RBAC features.
- Documented import/export field mappings.
- Added import service for CSV/Excel.
- Expanded import workflow with previews and commits.
- Added audit logging for imports/exports.
## 2025-11-09
- Captured current implementation status, requirements coverage, missing features, and prioritized roadmap in `calminer-docs/implementation_status.md` to guide future development.
- Added core SQLAlchemy domain models, shared metadata descriptors, and Alembic migration setup (with initial schema snapshot) to establish the persistence layer foundation.
- Introduced repository and unit-of-work helpers for projects, scenarios, financial inputs, and simulation parameters to support service-layer operations.
- Added SQLite-backed pytest coverage for repository and unit-of-work behaviours to validate persistence interactions.
- Exposed project and scenario CRUD APIs with validated schemas and integrated them into the FastAPI application.
- Connected project and scenario routers to new Jinja2 list/detail/edit views with HTML forms and redirects.
- Implemented FR-009 client-side enhancements with responsive navigation toggle, mobile-first scenario tables, and shared asset loading across templates.
- Added scenario comparison validator, FastAPI comparison endpoint, and comprehensive unit tests to enforce FR-009 validation rules through API errors.
- Delivered a new dashboard experience with `templates/dashboard.html`, dedicated styling, and a FastAPI route supplying real project/scenario metrics via repository helpers.
- Extended repositories with count/recency utilities and added pytest coverage, including a dashboard rendering smoke test validating empty-state messaging.
- Brought project and scenario detail pages plus their forms in line with the dashboard visuals, adding metric cards, layout grids, and refreshed CTA styles.
- Reordered project route registration to prioritize static UI paths, eliminating 422 errors on `/projects/ui` and `/projects/create`, and added pytest smoke coverage for the navigation endpoints.
- Added end-to-end integration tests for project and scenario lifecycles, validating HTML redirects, template rendering, and API interactions, and updated `ProjectRepository.get` to deduplicate joined loads for detail views.
- Updated all Jinja2 template responses to the new Starlette signature to eliminate deprecation warnings while keeping request-aware context available to the templates.
- Introduced `services/security.py` to centralize Argon2 password hashing utilities and JWT creation/verification with typed payloads, and added pytest coverage for hashing, expiry, tampering, and token type mismatch scenarios.
- Added `routes/auth.py` with registration, login, and password reset flows, refreshed auth templates with error messaging, wired navigation links, and introduced end-to-end pytest coverage for the new forms and token flows.
- Implemented cookie-based authentication session middleware with automatic access token refresh, logout handling, navigation adjustments, and documentation/test updates capturing the new behaviour.
- Delivered idempotent seeding utilities with `scripts/initial_data.py`, entry-point runner `scripts/00_initial_data.py`, documentation updates, and pytest coverage to verify role/admin provisioning.
- Secured project and scenario routers with RBAC guard dependencies, enforced repository access checks via helper utilities, and aligned template routes with FastAPI dependency injection patterns.
- Captured implementation status and roadmap.
- Added core database models and migration setup.
- Introduced repository helpers for data operations.
- Added tests for repository behaviors.
- Exposed CRUD APIs for projects and scenarios.
- Connected routers to HTML views.
- Implemented client-side enhancements.
- Added scenario comparison validator.
- Delivered new dashboard experience.
- Extended repositories with utilities.
- Updated detail pages with new visuals.
- Fixed route registration issues.
- Added end-to-end tests for lifecycles.
- Updated template responses.
- Introduced security utilities.
- Added authentication routes.
- Implemented session middleware.
- Delivered seeding utilities.
- Secured routers with RBAC.

View File

@@ -2,11 +2,7 @@ version: "3.8"
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
APT_CACHE_URL: ${APT_CACHE_URL:-}
image: git.allucanget.biz/allucanget/calminer:latest
environment:
- ENVIRONMENT=production
- DEBUG=false

View File

@@ -1,5 +1,3 @@
version: "3.8"
services:
app:
build:

2
run_docker.ps1 Normal file
View File

@@ -0,0 +1,2 @@
docker run -d --name calminer-app --env-file .env -p 8003:8003 -v "${PWD}\logs:/app/logs" --restart unless-stopped calminer:latest
docker logs -f calminer-app

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import List, Optional
from typing import List
from pydantic import BaseModel, Field, PositiveFloat, ValidationError, field_validator

View File

@@ -1102,7 +1102,7 @@ def seed_navigation(engine: Engine, is_sqlite: bool) -> None:
)
link_insert_sql = text(
f"""
"""
INSERT INTO navigation_links (
group_id, parent_link_id, slug, label, route_name, href_override,
match_prefix, sort_order, icon, tooltip, required_roles, is_enabled, is_external

View File

@@ -25,7 +25,6 @@ from schemas.calculations import (
CapexCalculationResult,
CapexCategoryBreakdown,
CapexComponentInput,
CapexParameters,
CapexTotals,
CapexTimelineEntry,
CashFlowEntry,

View File

@@ -1,11 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable, List, Optional, Sequence
from typing import Iterable, List, Sequence
from fastapi import Request
from models.navigation import NavigationGroup, NavigationLink
from models.navigation import NavigationLink
from services.repositories import NavigationRepository
from services.session import AuthSession
@@ -92,7 +92,7 @@ class NavigationService:
) -> List[NavigationLinkDTO]:
resolved_roles = tuple(roles)
mapped: List[NavigationLinkDTO] = []
for link in sorted(links, key=lambda l: (l.sort_order, l.id)):
for link in sorted(links, key=lambda x: (x.sort_order, x.id)):
if not include_children and link.parent_link_id is not None:
continue
if not include_disabled and (not link.is_enabled):

View File

@@ -2,17 +2,6 @@
--dashboard-gap: 1.5rem;
}
.dashboard-header {
align-items: center;
}
.header-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.dashboard-metrics {
display: grid;
gap: var(--dashboard-gap);
@@ -20,36 +9,6 @@
margin-bottom: 2rem;
}
.metric-card {
background: var(--card);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.metric-card h2 {
margin: 0;
font-size: 1rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
}
.metric-caption {
color: var(--color-text-subtle);
font-size: 0.85rem;
}
.dashboard-grid {
display: grid;
gap: var(--dashboard-gap);
@@ -67,16 +26,6 @@
gap: var(--dashboard-gap);
}
.table-link {
color: var(--brand-2);
text-decoration: none;
}
.table-link:hover,
.table-link:focus {
text-decoration: underline;
}
.timeline {
list-style: none;
margin: 0;
@@ -107,7 +56,9 @@
padding: 0.75rem;
border-radius: var(--radius-sm);
background: rgba(209, 75, 75, 0.16);
background: color-mix(in srgb, var(--color-danger) 16%, transparent);
border: 1px solid rgba(209, 75, 75, 0.3);
border: 1px solid color-mix(in srgb, var(--color-danger) 30%, transparent);
}
.links-list a {
@@ -128,23 +79,4 @@
.grid-sidebar {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.header-actions {
justify-content: flex-start;
}
}
@media (max-width: 640px) {
.metric-card {
padding: 1.25rem;
}
.metric-value {
font-size: 1.75rem;
}
.header-actions {
flex-direction: column;
align-items: stretch;
}
}

111
static/css/forms.css Normal file
View File

@@ -0,0 +1,111 @@
.form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text);
color: var(--color-text-primary);
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 0.75rem 0.85rem;
border-radius: var(--radius-sm);
border: 1px solid var(--card-border);
background: rgba(8, 12, 19, 0.78);
background: color-mix(in srgb, var(--color-bg-elevated) 78%, transparent);
color: var(--text);
color: var(--color-text-primary);
transition: border-color 0.15s ease, background 0.2s ease,
box-shadow 0.2s ease;
}
.form-group textarea {
resize: vertical;
min-height: 120px;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: 2px solid var(--brand-2);
outline: 2px solid var(--color-brand-bright);
outline-offset: 1px;
}
.form-group input:disabled,
.form-group select:disabled,
.form-group textarea:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.form-group--error input,
.form-group--error select,
.form-group--error textarea {
border-color: rgba(209, 75, 75, 0.6);
border-color: color-mix(in srgb, var(--color-danger) 60%, transparent);
box-shadow: 0 0 0 1px rgba(209, 75, 75, 0.3);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-danger) 30%, transparent);
}
.field-help {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-subtle);
}
.field-error {
margin: 0;
font-size: 0.85rem;
color: var(--danger);
color: var(--color-danger);
}
.form-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: flex-end;
}
.form-fieldset {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: rgba(21, 27, 35, 0.85);
background: var(--color-surface-overlay);
box-shadow: var(--shadow);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-fieldset legend {
font-weight: 700;
padding: 0 0.5rem;
color: var(--text);
color: var(--color-text-primary);
}
@media (max-width: 640px) {
.form-actions {
justify-content: stretch;
}
}

View File

@@ -1,7 +1,8 @@
.import-upload {
background-color: var(--surface-color);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
background-color: rgba(21, 27, 35, 0.85);
background-color: var(--color-surface-overlay);
border: 1px dashed var(--color-border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
@@ -11,7 +12,7 @@
}
.import-upload__dropzone {
border: 2px dashed var(--border-color);
border: 2px dashed var(--color-border);
border-radius: var(--radius-sm);
padding: 2rem;
text-align: center;
@@ -19,8 +20,10 @@
}
.import-upload__dropzone.dragover {
border-color: var(--primary-color);
background-color: rgba(0, 123, 255, 0.05);
border-color: #f6c648;
border-color: var(--color-brand-bright);
background-color: rgba(241, 178, 26, 0.08);
background-color: var(--color-highlight);
}
.import-upload__actions {
@@ -35,18 +38,6 @@
gap: 0.5rem;
}
.btn-ghost {
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
color: var(--text-muted);
}
.btn-ghost:hover {
color: var(--primary-color);
}
.toast {
position: fixed;
right: 1rem;
@@ -55,9 +46,9 @@
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-md);
color: #fff;
box-shadow: var(--shadow-lg);
border-radius: var(--radius);
color: var(--color-text-invert);
box-shadow: var(--shadow);
z-index: 1000;
}
@@ -66,15 +57,18 @@
}
.toast--success {
background-color: #198754;
background-color: var(--success);
background-color: var(--color-success);
}
.toast--error {
background-color: #dc3545;
background-color: var(--danger);
background-color: var(--color-danger);
}
.toast--info {
background-color: #0d6efd;
background-color: var(--info);
background-color: var(--color-info);
}
.toast__close {

View File

@@ -1,29 +1,12 @@
:root {
--bg: #0b0f14;
--bg-2: #0f141b;
--card: #151b23;
--text: #e6edf3;
--muted: #a9b4c0;
--brand: #f1b21a;
--brand-2: #f6c648;
--brand-3: #f9d475;
--accent: #2ba58f;
--danger: #d14b4b;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
/* Radii & layout */
--radius: 14px;
--radius-sm: 10px;
--panel-radius: var(--radius);
--table-radius: var(--radius-sm);
--container: 1180px;
--muted: var(--muted);
--color-text-subtle: rgba(169, 180, 192, 0.6);
--color-text-invert: #ffffff;
--color-text-dark: #0f172a;
--color-text-strong: #111827;
--color-border: rgba(255, 255, 255, 0.08);
--color-border-strong: rgba(255, 255, 255, 0.12);
--color-highlight: rgba(241, 178, 26, 0.08);
--color-panel-shadow: rgba(0, 0, 0, 0.25);
--color-panel-shadow-deep: rgba(0, 0, 0, 0.35);
--color-surface-alt: rgba(21, 27, 35, 0.7);
/* Spacing & typography */
--space-2xs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
@@ -31,18 +14,13 @@
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
--panel-radius: var(--radius);
--table-radius: var(--radius-sm);
}
* {
box-sizing: border-box;
}
html,
@@ -59,6 +37,13 @@ body {
line-height: 1.45;
}
.header-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
h1,
h2,
h3,
@@ -166,6 +151,36 @@ a {
color: var(--text);
}
.metric-card {
background: var(--color-surface-overlay);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.metric-card h2 {
margin: 0;
font-size: 1rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
}
.metric-caption {
color: var(--color-text-subtle);
font-size: 0.85rem;
}
.metrics-table {
width: 100%;
border-collapse: collapse;
@@ -183,7 +198,7 @@ a {
.metrics-table th {
font-weight: 600;
color: var(--text);
color: var(--color-text-dark);
}
.metrics-table tr:last-child td,
@@ -194,23 +209,30 @@ a {
.definition-list {
margin: 0;
display: grid;
gap: 0.75rem;
gap: 1.25rem 2rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.definition-list div {
display: grid;
grid-template-columns: 140px 1fr;
grid-template-columns: minmax(140px, 0.6fr) minmax(0, 1fr);
gap: 0.5rem;
align-items: baseline;
}
.definition-list dt {
color: var(--muted);
margin: 0;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.08em;
}
.definition-list dd {
margin: 0;
font-size: 1rem;
color: var(--color-text-primary);
}
.scenario-card {
@@ -240,6 +262,13 @@ a {
}
.scenario-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
}
.scenario-card .scenario-meta {
display: block;
text-align: right;
}
@@ -285,6 +314,201 @@ a {
color: var(--muted);
}
.quick-link-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.quick-link-list li a {
font-weight: 600;
color: var(--brand-2);
text-decoration: none;
}
.quick-link-list li a:hover,
.quick-link-list li a:focus {
text-decoration: underline;
}
.quick-link-list p {
margin: 0.25rem 0 0;
color: var(--color-text-subtle);
font-size: 0.9rem;
}
.scenario-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-item {
background: rgba(21, 27, 35, 0.85);
background: color-mix(in srgb, var(--color-surface-default) 85%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-item__body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-item__header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
}
.scenario-item__header h3 {
margin: 0;
font-size: 1.1rem;
}
.scenario-item__header a {
color: inherit;
text-decoration: none;
}
.scenario-item__header a:hover,
.scenario-item__header a:focus {
text-decoration: underline;
}
.scenario-item__meta {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.scenario-item__meta dt {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.scenario-item__meta dd {
margin: 0;
font-size: 0.95rem;
}
.scenario-item__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.scenario-item__actions .btn--link {
padding: 0;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status-pill--draft {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
background: color-mix(in srgb, var(--color-info) 18%, transparent);
color: color-mix(in srgb, var(--color-info) 70%, white);
}
.status-pill--active {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
background: color-mix(in srgb, var(--color-success) 18%, transparent);
color: color-mix(in srgb, var(--color-success) 70%, white);
}
.status-pill--archived {
background: rgba(148, 163, 184, 0.24);
color: #cbd5f5;
background: color-mix(in srgb, var(--color-text-muted) 24%, transparent);
color: color-mix(in srgb, var(--color-text-muted) 60%, white);
}
.empty-state {
color: var(--color-text-muted);
font-style: italic;
}
.table {
width: 100%;
border-collapse: collapse;
border-radius: var(--table-radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.table th,
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
background: rgba(21, 27, 35, 0.85);
background: color-mix(in srgb, var(--color-surface-default) 85%, transparent);
}
.table tbody tr:hover {
background: rgba(241, 178, 26, 0.12);
background: var(--color-highlight);
}
.table-link {
color: var(--brand-2);
text-decoration: none;
margin-left: 0.5rem;
}
.table-link:hover,
.table-link:focus {
text-decoration: underline;
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: var(--table-radius);
margin: 0;
}
.table-responsive .table {
min-width: 640px;
}
.table-responsive::-webkit-scrollbar {
height: 6px;
}
.table-responsive::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
background: color-mix(in srgb, var(--color-text-invert) 20%, transparent);
border-radius: 999px;
}
.page-actions .button {
text-decoration: none;
background: transparent;
@@ -545,7 +769,7 @@ a.sidebar-brand:focus {
.dashboard-header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 2rem;
@@ -887,36 +1111,6 @@ a.sidebar-brand:focus {
font-size: var(--font-size-lg);
}
.form-grid {
display: grid;
gap: var(--space-md);
max-width: 480px;
}
.form-grid label {
display: flex;
flex-direction: column;
gap: var(--space-sm);
font-weight: 600;
color: var(--text);
}
.form-grid input,
.form-grid textarea,
.form-grid select {
padding: 0.6rem var(--space-sm);
border: 1px solid var(--color-border-strong);
border-radius: 8px;
font-size: var(--font-size-base);
}
.form-grid input:focus,
.form-grid textarea:focus,
.form-grid select:focus {
outline: 2px solid var(--brand-2);
outline-offset: 1px;
}
.btn {
display: inline-flex;
align-items: center;
@@ -924,37 +1118,101 @@ a.sidebar-brand:focus {
gap: 0.5rem;
padding: 0.65rem 1.25rem;
border-radius: 999px;
border: none;
border: 1px solid var(--btn-secondary-border);
cursor: pointer;
font-weight: 600;
background-color: var(--brand);
color: var(--color-text-dark);
background-color: var(--btn-secondary-bg);
color: var(--btn-secondary-color);
text-decoration: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
transition: transform 0.15s ease, box-shadow 0.15s ease,
background-color 0.2s ease, border-color 0.2s ease;
}
.btn:hover,
.btn:focus {
transform: translateY(-1px);
box-shadow: 0 4px 10px var(--color-panel-shadow);
background-color: var(--btn-secondary-hover);
}
.btn.primary {
background-color: var(--brand-2);
color: var(--color-text-dark);
.btn--primary,
.btn.primary,
.btn.btn-primary {
background-color: var(--btn-primary-bg);
border-color: transparent;
color: var(--btn-primary-color);
}
.btn--primary:hover,
.btn--primary:focus,
.btn.primary:hover,
.btn.primary:focus {
background-color: var(--brand-3);
.btn.primary:focus,
.btn.btn-primary:hover,
.btn.btn-primary:focus {
background-color: var(--btn-primary-hover);
}
.btn.btn-link {
background: var(--brand);
color: var(--color-text-dark);
text-decoration: none;
border: 1px solid var(--brand);
margin-bottom: 0.5rem;
.btn--secondary,
.btn.secondary,
.btn.btn-secondary {
background-color: var(--btn-secondary-bg);
border-color: var(--btn-secondary-border);
color: var(--btn-secondary-color);
}
.btn--secondary:hover,
.btn--secondary:focus,
.btn.secondary:hover,
.btn.secondary:focus,
.btn.btn-secondary:hover,
.btn.btn-secondary:focus {
background-color: var(--btn-secondary-hover);
}
.btn--link,
.btn.btn-link,
.btn.link {
padding: 0.25rem 0;
border: none;
background: transparent;
color: var(--btn-link-color);
margin: 0;
box-shadow: none;
}
.btn--link:hover,
.btn--link:focus,
.btn.btn-link:hover,
.btn.btn-link:focus,
.btn.link:hover,
.btn.link:focus {
transform: none;
box-shadow: none;
color: var(--btn-link-hover);
text-decoration: underline;
}
.btn--ghost {
background: transparent;
border: 1px solid transparent;
color: var(--btn-ghost-color);
}
.btn--ghost:hover,
.btn--ghost:focus {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.btn--icon {
padding: 0.4rem;
border-radius: 50%;
line-height: 0;
}
.btn--icon:hover,
.btn--icon:focus {
transform: none;
}
.result-output {
@@ -1156,10 +1414,62 @@ footer a:focus {
transition: opacity 0.25s ease;
}
@media (min-width: 720px) {
.table-responsive .table {
min-width: 100%;
}
}
@media (max-width: 640px) {
.table th,
.table td {
padding: 0.55rem 0.65rem;
font-size: 0.9rem;
white-space: nowrap;
}
.table tbody tr {
border-radius: var(--radius-sm);
}
.metric-card {
padding: 1.25rem;
}
.metric-value {
font-size: 1.75rem;
}
.header-actions {
flex-direction: column;
align-items: stretch;
}
}
@media (min-width: 960px) {
.header-actions {
justify-content: flex-start;
}
.scenario-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.scenario-item__body {
max-width: 70%;
}
}
@media (max-width: 1024px) {
.app-sidebar {
width: 240px;
}
.header-actions {
justify-content: flex-start;
}
}
@media (max-width: 900px) {

View File

@@ -1,16 +1,3 @@
:root {
--card-bg: rgba(21, 27, 35, 0.8);
--card-border: rgba(255, 255, 255, 0.08);
--hover-highlight: rgba(241, 178, 26, 0.12);
}
.header-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.projects-grid {
display: grid;
gap: 1.5rem;
@@ -19,8 +6,8 @@
}
.project-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
box-shadow: var(--shadow);
border-radius: var(--radius);
padding: 1.5rem;
@@ -33,7 +20,7 @@
.project-card:hover,
.project-card:focus-within {
transform: translateY(-2px);
box-shadow: 0 22px 45px rgba(0, 0, 0, 0.35);
box-shadow: 0 22px 45px var(--color-panel-shadow-deep);
}
.project-card__header {
@@ -85,7 +72,7 @@
.project-card__meta dt {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--muted);
color: var(--color-text-muted);
letter-spacing: 0.08em;
}
@@ -108,7 +95,7 @@
flex-wrap: wrap;
}
.project-card__links .btn-link {
.project-card__links .btn--link {
padding: 3px 4px;
border-radius: 8px;
}
@@ -120,39 +107,9 @@
margin-bottom: 2rem;
}
.metric-card {
background: var(--card-bg);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--card-border);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.metric-card h2 {
margin: 0;
font-size: 1rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
}
.metric-caption {
color: var(--color-text-subtle);
font-size: 0.85rem;
}
.project-form {
background: var(--card-bg);
border: 1px solid var(--card-border);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.75rem;
@@ -161,28 +118,9 @@
gap: 1.5rem;
}
.definition-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.25rem 2rem;
}
.definition-list dt {
font-weight: 600;
color: var(--muted);
margin-bottom: 0.2rem;
text-transform: uppercase;
font-size: 0.75rem;
}
.definition-list dd {
margin: 0;
font-size: 1rem;
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
box-shadow: var(--shadow);
border-radius: var(--radius);
padding: 1.5rem;
@@ -200,32 +138,6 @@
gap: 1rem;
}
.quick-link-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.quick-link-list li a {
font-weight: 600;
color: var(--brand-2);
text-decoration: none;
}
.quick-link-list li a:hover,
.quick-link-list li a:focus {
text-decoration: underline;
}
.quick-link-list p {
margin: 0.25rem 0 0;
color: var(--color-text-subtle);
font-size: 0.9rem;
}
.project-scenarios-card {
display: flex;
flex-direction: column;
@@ -243,109 +155,6 @@
margin: 0;
}
.scenario-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-item {
background: rgba(21, 27, 35, 0.85);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-item__body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-item__header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
}
.scenario-item__header h3 {
margin: 0;
font-size: 1.1rem;
}
.scenario-item__header a {
color: inherit;
text-decoration: none;
}
.scenario-item__header a:hover,
.scenario-item__header a:focus {
text-decoration: underline;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.status-pill--draft {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
}
.status-pill--active {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
}
.status-pill--archived {
background: rgba(148, 163, 184, 0.24);
color: #cbd5f5;
}
.scenario-item__meta {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.scenario-item__meta dt {
margin: 0;
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.scenario-item__meta dd {
margin: 0;
font-size: 0.95rem;
}
.scenario-item__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.scenario-item__actions .btn-link {
padding: 0;
}
.card-header {
display: flex;
align-items: center;
@@ -362,41 +171,6 @@
gap: 1.5rem;
}
.table-responsive {
overflow-x: auto;
border-radius: var(--table-radius);
}
.table {
width: 100%;
border-collapse: collapse;
border-radius: var(--table-radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.table th,
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--card-border);
background: rgba(21, 27, 35, 0.85);
}
.table tbody tr:hover {
background: var(--hover-highlight);
}
.table-link {
color: var(--brand-2);
text-decoration: none;
margin-left: 0.5rem;
}
.table-link:hover,
.table-link:focus {
text-decoration: underline;
}
.text-right {
text-align: right;
}
@@ -406,52 +180,4 @@
grid-template-columns: 1.1fr 1.9fr;
align-items: start;
}
.header-actions {
justify-content: flex-start;
}
.scenario-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.scenario-item__body {
max-width: 70%;
}
}
.form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 0.75rem 0.85rem;
border-radius: var(--radius-sm);
border: 1px solid var(--card-border);
background: rgba(8, 12, 19, 0.75);
color: var(--text);
}
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}

View File

@@ -1,35 +1,3 @@
.scenario-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
}
.table {
width: 100%;
border-collapse: collapse;
border-radius: var(--table-radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.table th,
.table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
background: rgba(21, 27, 35, 0.85);
}
.table tbody tr:hover {
background: rgba(43, 165, 143, 0.12);
}
.header-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.scenario-metrics {
display: grid;
gap: 1.5rem;
@@ -37,36 +5,6 @@
margin-bottom: 2rem;
}
.metric-card {
background: rgba(21, 27, 35, 0.85);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.metric-card h2 {
margin: 0;
font-size: 1rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
}
.metric-caption {
color: var(--color-text-subtle);
font-size: 0.85rem;
}
.scenario-filters {
display: grid;
gap: 0.75rem;
@@ -93,11 +31,13 @@
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: rgba(8, 12, 19, 0.75);
color: var(--text);
background: color-mix(in srgb, var(--color-bg-elevated) 75%, transparent);
color: var(--color-text-primary);
}
.scenario-form {
background: rgba(21, 27, 35, 0.85);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
@@ -106,9 +46,11 @@
flex-direction: column;
gap: 1.5rem;
}
.scenario-form .card {
background: rgba(21, 27, 35, 0.9);
border: 1px solid var(--card-border);
background: color-mix(in srgb, var(--color-surface-default) 90%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.5rem;
display: flex;
@@ -120,121 +62,11 @@
margin: 0;
}
.scenario-form .form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.25rem;
}
.scenario-form .form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.scenario-form .form-group label {
font-weight: 600;
color: var(--text);
}
.scenario-form .form-group input,
.scenario-form .form-group select,
.scenario-form .form-group textarea {
padding: 0.75rem 0.85rem;
border-radius: var(--radius-sm);
border: 1px solid var(--card-border);
background: rgba(8, 12, 19, 0.78);
color: var(--text);
}
.scenario-form .form-group textarea {
resize: vertical;
}
.scenario-form .form-group input:focus,
.scenario-form .form-group select:focus,
.scenario-form .form-group textarea:focus {
outline: 2px solid var(--brand-2);
outline-offset: 1px;
}
.form-group--error input,
.form-group--error select,
.form-group--error textarea {
border-color: rgba(209, 75, 75, 0.6);
box-shadow: 0 0 0 1px rgba(209, 75, 75, 0.3);
}
.field-help {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-subtle);
}
.field-error {
margin: 0;
font-size: 0.85rem;
color: var(--danger);
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: var(--table-radius);
margin: 0;
}
.table-responsive .table {
min-width: 640px;
}
.table-responsive::-webkit-scrollbar {
height: 6px;
}
.table-responsive::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 999px;
}
@media (min-width: 720px) {
.scenario-filters {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: end;
}
.scenario-filters .filter-actions {
justify-content: flex-end;
}
.table-responsive .table {
min-width: 100%;
}
}
@media (max-width: 640px) {
.breadcrumb {
flex-wrap: wrap;
gap: 0.35rem;
}
.table th,
.table td {
padding: 0.55rem 0.65rem;
font-size: 0.9rem;
white-space: nowrap;
}
.table tbody tr {
border-radius: var(--radius-sm);
}
}
.scenario-layout {
display: grid;
gap: 1.5rem;
}
.scenario-column {
display: grid;
gap: 1.5rem;
@@ -246,70 +78,6 @@
gap: 1rem;
}
.quick-link-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.quick-link-list li a {
font-weight: 600;
color: var(--brand-2);
text-decoration: none;
}
.quick-link-list li a:hover,
.quick-link-list li a:focus {
text-decoration: underline;
}
.quick-link-list p {
margin: 0.25rem 0 0;
color: var(--color-text-subtle);
font-size: 0.9rem;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status-pill--draft {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
}
.status-pill--active {
background: rgba(34, 197, 94, 0.18);
color: #86efac;
}
.status-pill--archived {
background: rgba(148, 163, 184, 0.24);
color: #cbd5f5;
}
@media (min-width: 960px) {
.scenario-layout {
grid-template-columns: 1.1fr 1.9fr;
align-items: start;
}
}
.empty-state {
color: var(--muted);
font-style: italic;
}
.scenario-portfolio {
display: flex;
flex-direction: column;
@@ -323,95 +91,6 @@
gap: 1rem;
}
.scenario-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-item {
background: rgba(21, 27, 35, 0.85);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-item__body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-item__header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
}
.scenario-item__header h3 {
margin: 0;
font-size: 1.1rem;
}
.scenario-item__header a {
color: inherit;
text-decoration: none;
}
.scenario-item__header a:hover,
.scenario-item__header a:focus {
text-decoration: underline;
}
.scenario-item__meta {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.scenario-item__meta dt {
margin: 0;
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.scenario-item__meta dd {
margin: 0;
font-size: 0.95rem;
}
.scenario-item__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.scenario-item__actions .btn-link {
padding: 0;
}
@media (min-width: 960px) {
.scenario-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.scenario-item__body {
max-width: 70%;
}
}
.scenario-context-card {
display: flex;
flex-direction: column;
@@ -440,14 +119,34 @@
font-size: 0.9rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
color: var(--color-text-muted);
}
.scenario-layout .table tbody tr:hover,
.scenario-portfolio .table tbody tr:hover {
background: rgba(43, 165, 143, 0.12);
background: color-mix(in srgb, var(--color-accent) 18%, transparent);
}
@media (min-width: 720px) {
.scenario-filters {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: end;
}
.scenario-filters .filter-actions {
justify-content: flex-end;
}
}
@media (max-width: 640px) {
.breadcrumb {
flex-wrap: wrap;
gap: 0.35rem;
}
}
@media (min-width: 960px) {
.header-actions {
justify-content: flex-start;
}
.scenario-layout {
grid-template-columns: 1.1fr 1.9fr;
align-items: start;

View File

@@ -0,0 +1,72 @@
:root {
/* Neutral surfaces */
--color-bg-base: #0b0f14;
--color-bg-elevated: #0f141b;
--color-surface-default: #151b23;
--color-surface-overlay: rgba(21, 27, 35, 0.7);
--color-border-subtle: rgba(255, 255, 255, 0.08);
--color-border-card: rgba(255, 255, 255, 0.08);
--color-border-strong: rgba(255, 255, 255, 0.12);
--color-highlight: rgba(241, 178, 26, 0.08);
/* Text */
--color-text-primary: #e6edf3;
--color-text-muted: #a9b4c0;
--color-text-subtle: rgba(169, 180, 192, 0.6);
--color-text-invert: #ffffff;
--color-text-dark: #0f172a;
--color-text-strong: #111827;
/* Brand & accent */
--color-brand-base: #f1b21a;
--color-brand-bright: #f6c648;
--color-brand-soft: #f9d475;
--color-accent: #2ba58f;
/* Semantic states */
--color-success: #0c864d;
--color-info: #0b3d88;
--color-warning: #f59e0b;
--color-danger: #7a1721;
/* Shadows & depth */
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--color-panel-shadow: rgba(0, 0, 0, 0.25);
--color-panel-shadow-deep: rgba(0, 0, 0, 0.35);
/* Buttons */
--btn-primary-bg: var(--color-brand-bright);
--btn-primary-color: var(--color-text-dark);
--btn-primary-hover: var(--color-brand-soft);
--btn-secondary-bg: rgba(21, 27, 35, 0.85);
--btn-secondary-hover: rgba(21, 27, 35, 0.95);
--btn-secondary-border: var(--color-border-strong);
--btn-secondary-color: var(--color-text-primary);
--btn-danger-bg: var(--color-danger);
--btn-danger-color: var(--color-text-invert);
--btn-danger-hover: #a21d2b;
--btn-link-color: var(--color-brand-bright);
--btn-link-hover: var(--color-brand-soft);
--btn-ghost-color: var(--color-text-muted);
/* Legacy aliases */
--bg: var(--color-bg-base);
--bg-2: var(--color-bg-elevated);
--card: var(--color-surface-default);
--text: var(--color-text-primary);
--muted: var(--color-text-muted);
--brand: var(--color-brand-base);
--brand-2: var(--color-brand-bright);
--brand-3: var(--color-brand-soft);
--accent: var(--color-accent);
--success: var(--color-success);
--danger: var(--color-danger);
--info: var(--color-info);
--color-border: var(--color-border-subtle);
--card-border: var(--color-border-card);
--color-surface-alt: var(--color-surface-overlay);
}

View File

@@ -1,53 +0,0 @@
// Navigation chevron buttons logic
document.addEventListener("DOMContentLoaded", function () {
const navPrev = document.getElementById("nav-prev");
const navNext = document.getElementById("nav-next");
if (!navPrev || !navNext) return;
// Define the navigation order (main pages)
const navPages = [
window.NAVIGATION_URLS.dashboard,
window.NAVIGATION_URLS.projects,
window.NAVIGATION_URLS.imports,
window.NAVIGATION_URLS.simulations,
window.NAVIGATION_URLS.reporting,
window.NAVIGATION_URLS.settings,
];
const currentPath = window.location.pathname;
// Find current index
let currentIndex = -1;
for (let i = 0; i < navPages.length; i++) {
if (currentPath.startsWith(navPages[i])) {
currentIndex = i;
break;
}
}
// If not found, disable both
if (currentIndex === -1) {
navPrev.disabled = true;
navNext.disabled = true;
return;
}
// Set up prev button
if (currentIndex > 0) {
navPrev.addEventListener("click", function () {
window.location.href = navPages[currentIndex - 1];
});
} else {
navPrev.disabled = true;
}
// Set up next button
if (currentIndex < navPages.length - 1) {
navNext.addEventListener("click", function () {
window.location.href = navPages[currentIndex + 1];
});
} else {
navNext.disabled = true;
}
});

View File

@@ -3,6 +3,148 @@
const SIDEBAR_SELECTOR = ".sidebar-nav";
const DATA_SOURCE_ATTR = "navigationSource";
const ROLE_ATTR = "navigationRoles";
const NAV_PREV_ID = "nav-prev";
const NAV_NEXT_ID = "nav-next";
const CACHE_KEY = "calminer:navigation:sidebar";
const CACHE_VERSION = 1;
const CACHE_TTL_MS = 2 * 60 * 1000;
function hasStorage() {
try {
return typeof window.localStorage !== "undefined";
} catch (error) {
return false;
}
}
function loadCacheRoot() {
if (!hasStorage()) {
return null;
}
let raw;
try {
raw = window.localStorage.getItem(CACHE_KEY);
} catch (error) {
return null;
}
if (!raw) {
return { version: CACHE_VERSION, entries: {} };
}
try {
const parsed = JSON.parse(raw);
if (
!parsed ||
typeof parsed !== "object" ||
parsed.version !== CACHE_VERSION ||
typeof parsed.entries !== "object"
) {
return { version: CACHE_VERSION, entries: {} };
}
return parsed;
} catch (error) {
clearCache();
return { version: CACHE_VERSION, entries: {} };
}
}
function persistCache(root) {
if (!hasStorage()) {
return;
}
try {
window.localStorage.setItem(CACHE_KEY, JSON.stringify(root));
} catch (error) {
/* ignore storage write failures */
}
}
function clearCache() {
if (!hasStorage()) {
return;
}
try {
window.localStorage.removeItem(CACHE_KEY);
} catch (error) {
/* ignore */
}
}
function normaliseRoles(roles) {
if (!Array.isArray(roles)) {
return [];
}
const seen = new Set();
const cleaned = [];
for (const value of roles) {
const role = typeof value === "string" ? value.trim() : "";
if (!role || seen.has(role)) {
continue;
}
seen.add(role);
cleaned.push(role);
}
cleaned.sort();
return cleaned;
}
function serialiseRoles(roles) {
const cleaned = normaliseRoles(roles);
if (cleaned.length === 0) {
return "anonymous";
}
return cleaned.join("|");
}
function getCurrentRoles(navContainer) {
const attr = navContainer.dataset[ROLE_ATTR];
if (!attr) {
return null;
}
const roles = attr
.split(",")
.map((role) => role.trim())
.filter(Boolean);
if (roles.length === 0) {
return null;
}
return roles;
}
function readCache(rolesKey) {
if (!rolesKey) {
return null;
}
const root = loadCacheRoot();
if (!root || !root.entries || typeof root.entries !== "object") {
return null;
}
const entry = root.entries[rolesKey];
if (!entry || !entry.payload) {
return null;
}
const cachedAt = typeof entry.cachedAt === "number" ? entry.cachedAt : 0;
const expired = Date.now() - cachedAt > CACHE_TTL_MS;
return { payload: entry.payload, expired };
}
function saveCache(rolesKey, payload) {
if (!rolesKey || !payload) {
return;
}
const root = loadCacheRoot();
if (!root) {
return;
}
if (!root.entries || typeof root.entries !== "object") {
root.entries = {};
}
root.entries[rolesKey] = {
cachedAt: Date.now(),
payload,
};
root.version = CACHE_VERSION;
persistCache(root);
}
function onReady(callback) {
if (document.readyState === "loading") {
@@ -160,6 +302,102 @@
return section;
}
function resolvePath(input) {
if (!input) {
return null;
}
try {
return new URL(input, window.location.origin).pathname;
} catch (error) {
if (input.startsWith("/")) {
return input;
}
return `/${input}`;
}
}
function flattenNavigation(groups) {
const sequence = [];
for (const group of groups) {
if (!group || !Array.isArray(group.links)) {
continue;
}
for (const link of group.links) {
if (!link || !link.href) {
continue;
}
const isExternal = Boolean(link.is_external ?? link.isExternal);
if (!isExternal) {
sequence.push({
href: link.href,
matchPrefix: link.match_prefix || link.matchPrefix || link.href,
});
}
const children = Array.isArray(link.children) ? link.children : [];
for (const child of children) {
if (!child || !child.href) {
continue;
}
const childExternal = Boolean(child.is_external ?? child.isExternal);
if (childExternal) {
continue;
}
sequence.push({
href: child.href,
matchPrefix: child.match_prefix || child.matchPrefix || child.href,
});
}
}
}
return sequence;
}
function configureChevronButtons(sequence) {
const prevButton = document.getElementById(NAV_PREV_ID);
const nextButton = document.getElementById(NAV_NEXT_ID);
if (!prevButton || !nextButton) {
return;
}
const pathname = window.location.pathname;
const normalised = sequence
.map((item) => ({
href: item.href,
matchPrefix: item.matchPrefix,
path: resolvePath(item.matchPrefix || item.href),
}))
.filter((item) => Boolean(item.path));
const currentIndex = normalised.findIndex((item) =>
isActivePath(pathname, item.matchPrefix || item.path)
);
prevButton.disabled = true;
prevButton.onclick = null;
nextButton.disabled = true;
nextButton.onclick = null;
if (currentIndex === -1) {
return;
}
if (currentIndex > 0) {
const target = normalised[currentIndex - 1].href;
prevButton.disabled = false;
prevButton.onclick = () => {
window.location.href = target;
};
}
if (currentIndex < normalised.length - 1) {
const target = normalised[currentIndex + 1].href;
nextButton.disabled = false;
nextButton.onclick = () => {
window.location.href = target;
};
}
}
function renderSidebar(navContainer, payload) {
const pathname = window.location.pathname;
const groups = Array.isArray(payload?.groups) ? payload.groups : [];
@@ -177,6 +415,7 @@
navContainer.appendChild(buildEmptyState());
navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty";
delete navContainer.dataset[ROLE_ATTR];
configureChevronButtons([]);
return;
}
@@ -191,9 +430,22 @@
} else {
delete navContainer.dataset[ROLE_ATTR];
}
configureChevronButtons(flattenNavigation(groups));
}
async function hydrateSidebar(navContainer) {
const roles = getCurrentRoles(navContainer);
const rolesKey = roles ? serialiseRoles(roles) : null;
const cached = readCache(rolesKey);
if (cached && cached.payload) {
renderSidebar(navContainer, cached.payload);
if (!cached.expired) {
return;
}
}
try {
const response = await fetch(NAV_ENDPOINT, {
method: "GET",
@@ -204,6 +456,9 @@
});
if (!response.ok) {
if (!cached || !cached.payload) {
configureChevronButtons([]);
}
if (response.status !== 401 && response.status !== 403) {
console.warn(
"Navigation sidebar hydration failed with status",
@@ -215,14 +470,22 @@
const payload = await response.json();
renderSidebar(navContainer, payload);
const payloadRoles = Array.isArray(payload?.roles)
? payload.roles
: roles || [];
saveCache(serialiseRoles(payloadRoles), payload);
} catch (error) {
console.warn("Navigation sidebar hydration failed", error);
if (!cached || !cached.payload) {
configureChevronButtons([]);
}
}
}
onReady(() => {
const navContainer = document.querySelector(SIDEBAR_SELECTOR);
if (!navContainer) {
configureChevronButtons([]);
return;
}
hydrateSidebar(navContainer);

View File

@@ -4,7 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}CalMiner{% endblock %}</title>
<link rel="stylesheet" href="/static/css/theme-default.css" />
<link rel="stylesheet" href="/static/css/main.css" />
<link rel="stylesheet" href="/static/css/forms.css" />
<link rel="stylesheet" href="/static/css/imports.css" />
{% block head_extra %}{% endblock %}
</head>
@@ -42,7 +44,6 @@
<script src="/static/js/imports.js" defer></script>
<script src="/static/js/notifications.js" defer></script>
<script src="/static/js/navigation_sidebar.js" defer></script>
<script src="/static/js/navigation.js" defer></script>
<script src="/static/js/theme.js"></script>
</body>
</html>

View File

@@ -1,5 +1,4 @@
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {%
block head_extra %}
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {% block head_extra %}
<link rel="stylesheet" href="/static/css/dashboard.css" />
{% endblock %} {% block content %}
<section class="page-header dashboard-header">
@@ -165,12 +164,12 @@ block head_extra %}
</header>
<ul class="links-list">
<li>
<a href="https://github.com/" target="_blank">CalMiner Repository</a>
<a href="https://git.allucanget.biz/allucanget/calminer" target="_blank">CalMiner Repository</a>
</li>
<li>
<a href="https://example.com/docs" target="_blank">Documentation</a>
<a href="https://git.allucanget.biz/allucanget/calminer-docs" target="_blank">Documentation</a>
</li>
<li><a href="mailto:support@example.com">Contact Support</a></li>
<li><a href="mailto:calminer@allucanget.biz">Contact Support</a></li>
</ul>
</div>
</aside>

View File

@@ -9,7 +9,7 @@
<h5 class="modal-title">Export {{ dataset|capitalize }}</h5>
<button
type="button"
class="btn-close"
class="btn btn--ghost btn--icon"
data-dismiss="modal"
aria-label="Close"
></button>
@@ -40,10 +40,10 @@
>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
<button type="button" class="btn btn--secondary" data-dismiss="modal">
Cancel
</button>
<button type="submit" class="btn btn-primary">Download</button>
<button type="submit" class="btn btn--primary">Download</button>
</div>
<p class="form-error hidden" data-export-error></p>
</form>

View File

@@ -24,8 +24,8 @@
{% include "partials/import_preview_table.html" %}
<div class="import-actions hidden" data-import-actions>
<button class="btn primary" data-import-commit disabled>Commit Import</button>
<button class="btn" data-import-cancel>Cancel</button>
<button class="btn btn--primary" data-import-commit disabled>Commit Import</button>
<button class="btn btn--secondary" data-import-cancel>Cancel</button>
</div>
</div>
</section>

View File

@@ -26,7 +26,7 @@
<label for="password">Password:</label>
<input type="password" id="password" name="password" required />
</div>
<button type="submit" class="btn primary">Login</button>
<button type="submit" class="btn btn--primary">Login</button>
</form>
<p>Don't have an account? <a href="/register">Register here</a></p>
<p><a href="/forgot-password">Forgot password?</a></p>

View File

@@ -9,7 +9,7 @@
<div class="import-upload__dropzone" data-import-dropzone>
<span class="icon-upload" aria-hidden="true"></span>
<p>Drag & drop CSV/XLSX files here or</p>
<label class="btn secondary">
<label class="btn btn--secondary">
Browse
<input type="file" name="import-file" accept=".csv,.xlsx" hidden />
</label>
@@ -17,8 +17,8 @@
</div>
<div class="import-upload__actions">
<button type="button" class="btn primary" data-import-upload-trigger disabled>Upload & Preview</button>
<button type="button" class="btn" data-import-reset hidden>Reset</button>
<button type="button" class="btn btn--primary" data-import-upload-trigger disabled>Upload & Preview</button>
<button type="button" class="btn btn--secondary" data-import-reset hidden>Reset</button>
</div>
{{ feedback("import-upload-feedback", hidden=True, role="alert") }}

View File

@@ -1,4 +1,5 @@
{% set sidebar_nav = get_sidebar_navigation(request) %}
{% set nav_roles = sidebar_nav.roles if sidebar_nav and sidebar_nav.roles else [] %}
{% set nav_groups = sidebar_nav.groups if sidebar_nav else [] %}
{% set current_path = request.url.path if request else '' %}
@@ -6,6 +7,7 @@
class="sidebar-nav"
aria-label="Primary navigation"
data-navigation-source="{{ 'server' if sidebar_nav else 'fallback' }}"
data-navigation-roles="{{ nav_roles | join(',') }}"
>
<div class="sidebar-nav-controls">
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page"></button>

View File

@@ -17,9 +17,9 @@
<p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Manage Scenarios</a>
<a class="btn" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit Project</a>
<a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
<a class="btn btn--secondary" href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Manage Scenarios</a>
<a class="btn btn--secondary" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit Project</a>
<a class="btn btn--primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
</div>
</header>
@@ -99,7 +99,7 @@
<h2>Scenarios</h2>
<p class="text-muted">Project scenarios inherit pricing and provide entry points to profitability planning.</p>
</div>
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
<a class="btn btn--secondary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
</header>
{% if scenarios %}
<ul class="scenario-list">
@@ -126,8 +126,8 @@
</dl>
</div>
<div class="scenario-item__actions">
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
<a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
<a class="btn btn--link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
<a class="btn btn--link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
</div>
</li>
{% endfor %}

View File

@@ -26,8 +26,8 @@
<p class="text-muted">Provide core information about the mining project.</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Project</button>
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
<button class="btn btn--primary" type="submit">Save Project</button>
</div>
</header>
@@ -58,8 +58,8 @@
</div>
<div class="form-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Project</button>
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
<button class="btn btn--primary" type="submit">Save Project</button>
</div>
</form>
{% endblock %}

View File

@@ -19,7 +19,7 @@
data-project-filter
aria-label="Filter projects"
/>
<a class="btn btn-primary" href="{{ url_for('projects.create_project_form') }}">New Project</a>
<a class="btn btn--primary" href="{{ url_for('projects.create_project_form') }}">New Project</a>
</div>
</section>
@@ -55,12 +55,12 @@
<footer class="project-card__footer">
<div class="project-card__links">
<a class="btn btn-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View Project</a>
<a class="btn btn-link" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
<a class="btn btn-link" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
<a class="btn btn--link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View Project</a>
<a class="btn btn--link" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
<a class="btn btn--link" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
</div>
<button
class="btn btn-ghost"
class="btn btn--ghost"
data-export-trigger
data-export-target="projects"
title="Export projects dataset"

View File

@@ -36,7 +36,7 @@ content %}
<label for="password">Password:</label>
<input type="password" id="password" name="password" required />
</div>
<button type="submit" class="btn primary">Register</button>
<button type="submit" class="btn btn--primary">Register</button>
</form>
<p>Already have an account? <a href="/login">Login here</a></p>
</div>

View File

@@ -20,16 +20,16 @@
</div>
<div class="header-actions">
{% if scenario_url %}
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
<a class="btn btn--secondary" href="{{ scenario_url }}">Scenario Overview</a>
{% elif project_url %}
<a class="btn" href="{{ project_url }}">Project Overview</a>
<a class="btn btn--secondary" href="{{ project_url }}">Project Overview</a>
{% elif cancel_url %}
<a class="btn" href="{{ cancel_url }}">Back</a>
<a class="btn btn--secondary" href="{{ cancel_url }}">Back</a>
{% endif %}
{% if scenario_portfolio_url %}
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
<a class="btn btn--secondary" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
{% endif %}
<button class="btn primary" type="submit" form="capex-form">Save &amp; Calculate</button>
<button class="btn btn--primary" type="submit" form="capex-form">Save &amp; Calculate</button>
</div>
</header>
@@ -66,7 +66,7 @@
</header>
<div class="table-actions">
<button class="btn secondary" type="button" data-action="add-component">Add Component</button>
<button class="btn btn--secondary" type="button" data-action="add-component">Add Component</button>
</div>
{% if component_errors is defined and component_errors %}
@@ -131,7 +131,7 @@
<input type="text" name="components[{{ loop.index0 }}][notes]" value="{{ component.notes or '' }}" />
</td>
<td class="row-actions">
<button class="btn link" type="button" data-action="remove-component">Remove</button>
<button class="btn btn--link" type="button" data-action="remove-component">Remove</button>
</td>
</tr>
{% endfor %}

View File

@@ -30,11 +30,11 @@
</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ scenario_list_href }}">Scenario Portfolio</a>
<a class="btn" href="{{ profitability_href }}">Profitability Calculator</a>
<a class="btn" href="{{ opex_href }}">Opex Planner</a>
<a class="btn" href="{{ capex_href }}">Capex Planner</a>
<a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
<a class="btn btn--secondary" href="{{ scenario_list_href }}">Scenario Portfolio</a>
<a class="btn btn--secondary" href="{{ profitability_href }}">Profitability Calculator</a>
<a class="btn btn--secondary" href="{{ opex_href }}">Opex Planner</a>
<a class="btn btn--secondary" href="{{ capex_href }}">Capex Planner</a>
<a class="btn btn--primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
</div>
</header>

View File

@@ -32,8 +32,8 @@
<p class="text-muted">Scenarios inherit pricing defaults from <strong>{{ project.name }}</strong>.</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Scenario</button>
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
<button class="btn btn--primary" type="submit">Save Scenario</button>
</div>
</header>
@@ -145,8 +145,8 @@
</section>
<div class="form-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Scenario</button>
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
<button class="btn btn--primary" type="submit">Save Scenario</button>
</div>
</form>
{% endblock %}

View File

@@ -18,8 +18,8 @@
<p class="text-muted">Assumption sets and calculators for {{ project.name }}</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Project Overview</a>
<a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
<a class="btn btn--secondary" href="{{ url_for('projects.view_project', project_id=project.id) }}">Project Overview</a>
<a class="btn btn--primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
</div>
</header>
@@ -91,7 +91,7 @@
<h2>Scenario Portfolio</h2>
<p class="text-muted">Each scenario below inherits pricing defaults and links directly into calculators.</p>
</div>
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
<a class="btn btn--secondary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
</header>
{% if scenarios %}
<ul class="scenario-list">
@@ -125,11 +125,11 @@
</dl>
</div>
<div class="scenario-item__actions">
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
<a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
<a class="btn btn-link" href="{{ profitability_href }}">Profitability</a>
<a class="btn btn-link" href="{{ opex_href }}">Opex</a>
<a class="btn btn-link" href="{{ capex_href }}">Capex</a>
<a class="btn btn--link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
<a class="btn btn--link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
<a class="btn btn--link" href="{{ profitability_href }}">Profitability</a>
<a class="btn btn--link" href="{{ opex_href }}">Opex</a>
<a class="btn btn--link" href="{{ capex_href }}">Capex</a>
</div>
</li>
{% endfor %}

View File

@@ -20,16 +20,16 @@
</div>
<div class="header-actions">
{% if scenario_url %}
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
<a class="btn btn--secondary" href="{{ scenario_url }}">Scenario Overview</a>
{% elif project_url %}
<a class="btn" href="{{ project_url }}">Project Overview</a>
<a class="btn btn--secondary" href="{{ project_url }}">Project Overview</a>
{% elif cancel_url %}
<a class="btn" href="{{ cancel_url }}">Back</a>
<a class="btn btn--secondary" href="{{ cancel_url }}">Back</a>
{% endif %}
{% if scenario_portfolio_url %}
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
<a class="btn btn--secondary" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
{% endif %}
<button class="btn primary" type="submit" form="opex-form">Save &amp; Calculate</button>
<button class="btn btn--primary" type="submit" form="opex-form">Save &amp; Calculate</button>
</div>
</header>
@@ -66,7 +66,7 @@
</header>
<div class="table-actions">
<button class="btn secondary" type="button" data-action="add-opex-component">Add Component</button>
<button class="btn btn--secondary" type="button" data-action="add-opex-component">Add Component</button>
</div>
{% if component_errors %}
@@ -142,7 +142,7 @@
<input type="number" min="1" step="1" name="components[{{ loop.index0 }}][period_end]" value="{{ component.period_end or '' }}" />
</td>
<td class="row-actions">
<button class="btn link" type="button" data-action="remove-opex-component">Remove</button>
<button class="btn btn--link" type="button" data-action="remove-opex-component">Remove</button>
</td>
</tr>
{% endfor %}

View File

@@ -20,16 +20,16 @@
</div>
<div class="header-actions">
{% if scenario_url %}
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
<a class="btn btn--secondary" href="{{ scenario_url }}">Scenario Overview</a>
{% elif project_url %}
<a class="btn" href="{{ project_url }}">Project Overview</a>
<a class="btn btn--secondary" href="{{ project_url }}">Project Overview</a>
{% elif cancel_url %}
<a class="btn" href="{{ cancel_url }}">Back</a>
<a class="btn btn--secondary" href="{{ cancel_url }}">Back</a>
{% endif %}
{% if scenario_portfolio_url %}
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
<a class="btn btn--secondary" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
{% endif %}
<button class="btn primary" type="submit" form="profitability-form">Run Calculation</button>
<button class="btn btn--primary" type="submit" form="profitability-form">Run Calculation</button>
</div>
</header>

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import secrets
from datetime import datetime
from typing import Tuple, cast
@@ -70,7 +71,8 @@ def navigation_client() -> Tuple[TestClient, StubNavigationService, AuthSession]
user = cast(User, object())
session = AuthSession(
tokens=SessionTokens(access_token="token", refresh_token=None),
tokens=SessionTokens(
access_token=secrets.token_urlsafe(16), refresh_token=None),
user=user,
role_slugs=("viewer",),
)

View File

@@ -11,8 +11,8 @@ from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models import Role, User, UserRole
from dependencies import get_auth_session, require_current_user
from services.security import hash_password
from dependencies import get_auth_session, get_jwt_settings, require_current_user
from services.security import decode_access_token, hash_password
from services.session import AuthSession, SessionTokens
from tests.utils.security import random_password, random_token
@@ -334,6 +334,7 @@ class TestLoginFlowEndToEnd:
# Override to anonymous for login
app = cast(FastAPI, client.app)
original_override = app.dependency_overrides.get(get_auth_session)
app.dependency_overrides[get_auth_session] = lambda: AuthSession.anonymous(
)
try:
@@ -347,14 +348,21 @@ class TestLoginFlowEndToEnd:
"location") == "http://testserver/"
set_cookie_header = login_response.headers.get("set-cookie", "")
assert "calminer_access_token=" in set_cookie_header
# Now with cookies, GET / should show dashboard
dashboard_response = client.get("/")
assert dashboard_response.status_code == 200
assert "Dashboard" in dashboard_response.text or "metrics" in dashboard_response.text
finally:
app.dependency_overrides.pop(get_auth_session, None)
access_cookie = client.cookies.get("calminer_access_token")
refresh_cookie = client.cookies.get("calminer_refresh_token")
assert access_cookie, "Access token cookie was not set"
assert refresh_cookie, "Refresh token cookie was not set"
jwt_settings = get_jwt_settings()
payload = decode_access_token(access_cookie, jwt_settings)
assert payload.sub == str(user.id)
assert payload.scopes == ["auth"], "Unexpected access token scopes"
if original_override is not None:
app.dependency_overrides[get_auth_session] = original_override
def test_logout_redirects_to_login_and_clears_session(self, client: TestClient) -> None:
# Assuming authenticated from conftest
logout_response = client.get("/logout", follow_redirects=False)

View File

@@ -1,6 +1,7 @@
from fastapi.testclient import TestClient
from main import app
from scripts.init_db import init_db
def test_login_form_post_does_not_trigger_json_error():
@@ -8,6 +9,7 @@ def test_login_form_post_does_not_trigger_json_error():
the JSON "Invalid JSON payload" error which indicates the middleware
attempted to parse non-JSON bodies.
"""
init_db()
client = TestClient(app)
resp = client.post(

View File

@@ -43,9 +43,9 @@ def session(engine) -> Iterator[Session]:
def test_project_scenario_cascade_deletes(session: Session) -> None:
project = Project(name="Cascade Mine",
operation_type=MiningOperationType.OTHER)
scenario_a = Scenario(
Scenario(
name="Base Case", status=ScenarioStatus.DRAFT, project=project)
scenario_b = Scenario(
Scenario(
name="Expansion", status=ScenarioStatus.DRAFT, project=project)
session.add(project)