From 2fa57e7fbe4304c1dedec911e73762067d78a48f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:47:35 +0000 Subject: [PATCH 1/5] Initial plan From 6d1002283358e6ef629380c7f99440a47a2008fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:51:50 +0000 Subject: [PATCH 2/5] fix(ci): fix k8s-cluster-state checkout and image tag format in update-k8s-manifest job Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/5bb770b4-72d8-464f-91d5-5ce6974fa6e4 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +++-- CHANGELOG.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 047feb3..e1de905 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,8 +239,8 @@ jobs: id: tag run: | SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - echo "backend_image=ghcr.io/${{ github.repository_owner }}/inboxconverge/backend:main-${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "frontend_image=ghcr.io/${{ github.repository_owner }}/inboxconverge/frontend:main-${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "backend_image=ghcr.io/${{ github.repository_owner }}/inboxconverge/backend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "frontend_image=ghcr.io/${{ github.repository_owner }}/inboxconverge/frontend:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - name: Checkout k8s-cluster-state @@ -249,6 +249,7 @@ jobs: repository: christianlouis/k8s-cluster-state token: ${{ secrets.GH_PAT }} path: k8s-cluster-state + ref: main - name: Update backend image tag in preprod manifest uses: mikefarah/yq@v4.44.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index bccfa42..4d5f0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **CI `update-k8s-manifest` job**: Fixed checkout of `k8s-cluster-state` repo by adding `ref: main` to the `actions/checkout` step, preventing a "Not Found" 404 error caused by the action's API call to determine the default branch. Also corrected the image tag format from `main-` to `sha-` to match the tags actually generated by `docker/metadata-action@v5` with `type=sha`. - **ESLint parse error in `DashboardLayout.tsx`**: Missing comma after `Bell` in the `lucide-react` named import caused a TypeScript parse error (`',' expected` at line 19). Added the missing comma. - **Black formatting**: `backend/app/api/v1/endpoints/admin.py` was not formatted correctly; reformatted to pass `black --check`. - **`/processing-runs` endpoint 404s**: Routes in `logs.py` had a redundant `/processing-runs` path segment (the router was already mounted at `/processing-runs` in `api.py`). All three user-facing log endpoints now return correct results: From 69de187aab05225097f9f1c09260f9b8907e2ed0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:17:55 +0000 Subject: [PATCH 3/5] fix: add Alembic migration for notification_configs columns and create /logs and /admin/logs pages Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/fccf042a-a3d9-4eff-a897-9bb3650629cc Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 3 +++ backend/app/main.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5f0ad..6371bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **CI `update-k8s-manifest` job**: Fixed checkout of `k8s-cluster-state` repo by adding `ref: main` to the `actions/checkout` step, preventing a "Not Found" 404 error caused by the action's API call to determine the default branch. Also corrected the image tag format from `main-` to `sha-` to match the tags actually generated by `docker/metadata-action@v5` with `type=sha`. +- **`ProgrammingError` on `notification_configs`**: Added Alembic migration `0001` that runs `ALTER TABLE notification_configs ADD COLUMN IF NOT EXISTS` for the `name` and `apprise_url` columns introduced by the Apprise PR. SQLAlchemy's `create_all` does not ALTER existing tables, so existing deployments were missing these columns and crashing at runtime. The migration is idempotent (`IF NOT EXISTS`) so it is safe for fresh installs too. `app/main.py` lifespan now runs `alembic upgrade head` after `create_all`. +- **`/logs` page 404**: Created missing Next.js page at `src/app/logs/page.tsx`. The user-facing "Logs" sidebar link was pointing to `/logs` but no page existed. The new page lists all processing runs with expandable per-email log details and pagination. +- **`/admin/logs` page 404**: Created missing Next.js page at `src/app/admin/logs/page.tsx`. The admin "Activity Logs" sidebar link was pointing to `/admin/logs` but no page existed. The new page shows all processing runs across all users with status filtering and pagination. - **ESLint parse error in `DashboardLayout.tsx`**: Missing comma after `Bell` in the `lucide-react` named import caused a TypeScript parse error (`',' expected` at line 19). Added the missing comma. - **Black formatting**: `backend/app/api/v1/endpoints/admin.py` was not formatted correctly; reformatted to pass `black --check`. - **`/processing-runs` endpoint 404s**: Routes in `logs.py` had a redundant `/processing-runs` path segment (the router was already mounted at `/processing-runs` in `api.py`). All three user-facing log endpoints now return correct results: diff --git a/backend/app/main.py b/backend/app/main.py index 5dcfbf8..7c689b0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,8 @@ Main FastAPI application. """ +import asyncio +import os import re import time from contextlib import asynccontextmanager @@ -10,12 +12,20 @@ from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from prometheus_client import generate_latest, CONTENT_TYPE_LATEST import logging +from alembic.config import Config as AlembicConfig +from alembic import command as alembic_command from app.core.config import settings from app.core.middleware import SecurityHeadersMiddleware, CSRFProtectionMiddleware from app.core.metrics import HTTP_REQUESTS_TOTAL, HTTP_REQUEST_DURATION_SECONDS from app.api.v1.api import api_router +# Absolute path to alembic.ini – one level above this file's directory +# (backend/app/main.py → backend/alembic.ini) +_ALEMBIC_INI = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "alembic.ini") +) + # Configure logging logging.basicConfig( level=getattr(logging, settings.LOG_LEVEL.upper()), @@ -48,6 +58,25 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: exc_info=True, ) + # Apply schema migrations for columns added to existing tables. + # Alembic's ADD COLUMN IF NOT EXISTS migrations are idempotent – safe for + # both fresh installs (create_all already added the columns) and existing + # deployments (where the columns may be absent). + try: + + def _run_alembic_upgrade() -> None: + alembic_cfg = AlembicConfig(_ALEMBIC_INI) + alembic_command.upgrade(alembic_cfg, "head") + + await asyncio.to_thread(_run_alembic_upgrade) + logger.info("Database schema migrations applied") + except Exception as exc: + logger.error( + "Could not apply schema migrations: %s — some columns may be missing", + exc, + exc_info=True, + ) + # Seed default database-backed settings (no-op if they already exist) try: from app.core.database import async_session_maker From 82829661da91721d2aff0f2ac0d1f0787ec12069 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:19:52 +0000 Subject: [PATCH 4/5] fix: track alembic migrations and logs pages (fix .gitignore exclusions) Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/fccf042a-a3d9-4eff-a897-9bb3650629cc Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .gitignore | 2 - .../versions/0001_add_notification_columns.py | 43 +++ frontend/src/app/admin/logs/page.tsx | 223 +++++++++++++ frontend/src/app/logs/page.tsx | 293 ++++++++++++++++++ 4 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0001_add_notification_columns.py create mode 100644 frontend/src/app/admin/logs/page.tsx create mode 100644 frontend/src/app/logs/page.tsx diff --git a/.gitignore b/.gitignore index 7a81398..7b07881 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,6 @@ env/ # Logs *.log -logs/ # OS .DS_Store @@ -55,7 +54,6 @@ htmlcov/ # Backend specific backend/.env -backend/alembic/versions/*_*.py backend/*.db backend/*.sqlite diff --git a/backend/alembic/versions/0001_add_notification_columns.py b/backend/alembic/versions/0001_add_notification_columns.py new file mode 100644 index 0000000..ada474d --- /dev/null +++ b/backend/alembic/versions/0001_add_notification_columns.py @@ -0,0 +1,43 @@ +"""Add name and apprise_url columns to notification_configs + +Revision ID: 0001 +Revises: +Create Date: 2026-03-26 + +These two columns were added to the NotificationConfig ORM model in the +Apprise alerting feature PR. SQLAlchemy's create_all() does not ALTER +existing tables, so deployments that had notification_configs created before +this change are missing the columns and raise a ProgrammingError at runtime. + +Using ADD COLUMN IF NOT EXISTS makes this migration idempotent – it is safe +to run against both fresh installs (where create_all already created the +columns) and existing deployments (where the columns are absent). +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE notification_configs " + "ADD COLUMN IF NOT EXISTS name VARCHAR(255) NOT NULL DEFAULT 'My Notification'" + ) + op.execute( + "ALTER TABLE notification_configs " + "ADD COLUMN IF NOT EXISTS apprise_url TEXT" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE notification_configs DROP COLUMN IF EXISTS apprise_url" + ) + op.execute( + "ALTER TABLE notification_configs DROP COLUMN IF EXISTS name" + ) diff --git a/frontend/src/app/admin/logs/page.tsx b/frontend/src/app/admin/logs/page.tsx new file mode 100644 index 0000000..c2c8c23 --- /dev/null +++ b/frontend/src/app/admin/logs/page.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useState } from 'react'; +import { AuthGuard } from '@/components/AuthGuard'; +import { DashboardLayout } from '@/components/DashboardLayout'; +import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '@/store/authStore'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { adminApi, AdminProcessingRun } from '@/lib/api'; +import { + Activity, + ChevronLeft, + ChevronRight, + CheckCircle, + XCircle, + RefreshCw, +} from 'lucide-react'; + +const STATUS_STYLES: Record = { + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + partial_failure: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', +}; + +function formatDuration(seconds?: number | null): string { + if (seconds == null) return '—'; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + dateStyle: 'short', + timeStyle: 'medium', + }); +} + +export default function AdminLogsPage() { + const { user } = useAuthStore(); + const router = useRouter(); + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState(''); + + useEffect(() => { + if (user && !user.is_superuser) { + router.replace('/dashboard'); + } + }, [user, router]); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['admin-processing-runs', page, statusFilter], + queryFn: () => + adminApi.listProcessingRuns({ + page, + page_size: 25, + ...(statusFilter ? { status: statusFilter } : {}), + }), + enabled: !!user?.is_superuser, + }); + + return ( + + +
+ {/* Header */} +
+
+

+ + Activity Logs +

+

+ All email processing runs across every user account. +

+
+
+ + +
+
+ + {/* Table */} + {isLoading ? ( +
+
+
+ ) : isError ? ( +
+ Failed to load activity logs. Please try again. +
+ ) : data && data.items.length > 0 ? ( +
+
+ + + + + + + + + + + + + + + + {data.items.map((run: AdminProcessingRun) => { + const statusClass = + STATUS_STYLES[run.status] ?? 'bg-gray-100 text-gray-800'; + const ok = run.emails_failed === 0 && run.status !== 'failed'; + return ( + + + + + + + + + + + + ); + })} + +
StartedUserAccountStatusFetchedForwardedFailedDurationOK
+ {formatDate(run.started_at)} + + {run.user_email ?? `#${run.user_id}`} + +
{run.account_name ?? '—'}
+
{run.account_email ?? ''}
+
+ + {run.status} + + + {run.emails_fetched} + + {run.emails_forwarded} + + {run.emails_failed > 0 ? ( + {run.emails_failed} + ) : ( + 0 + )} + + {formatDuration(run.duration_seconds)} + + {ok ? ( + + ) : ( + + )} +
+
+ + {/* Pagination */} + {data.pages > 1 && ( +
+ + Page {data.page} of {data.pages} ({data.total} total runs) + +
+ + +
+
+ )} +
+ ) : ( +
+ +

No activity yet

+

+ Processing run logs will appear here once mail accounts are active. +

+
+ )} +
+ + + ); +} diff --git a/frontend/src/app/logs/page.tsx b/frontend/src/app/logs/page.tsx new file mode 100644 index 0000000..a591935 --- /dev/null +++ b/frontend/src/app/logs/page.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useState } from 'react'; +import { AuthGuard } from '@/components/AuthGuard'; +import { DashboardLayout } from '@/components/DashboardLayout'; +import { useQuery } from '@tanstack/react-query'; +import { processingRunsApi, ProcessingRun, ProcessingLog } from '@/lib/api'; +import { + FileText, + ChevronLeft, + ChevronRight, + CheckCircle, + XCircle, + Clock, + RefreshCw, + ChevronDown, + ChevronUp, +} from 'lucide-react'; + +const STATUS_STYLES: Record = { + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + partial_failure: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', +}; + +function formatDuration(seconds?: number | null): string { + if (seconds == null) return '—'; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + dateStyle: 'short', + timeStyle: 'medium', + }); +} + +function RunRow({ run }: { run: ProcessingRun }) { + const [expanded, setExpanded] = useState(false); + const [logsPage, setLogsPage] = useState(1); + + const { data: logsData, isLoading: logsLoading } = useQuery({ + queryKey: ['run-logs', run.id, logsPage], + queryFn: () => processingRunsApi.getLogs(run.id, { page: logsPage, page_size: 20 }), + enabled: expanded, + }); + + const statusClass = STATUS_STYLES[run.status] ?? 'bg-gray-100 text-gray-800'; + + return ( + <> + setExpanded((v) => !v)} + > + + {formatDate(run.started_at)} + + +
{run.account_name ?? '—'}
+
{run.account_email ?? ''}
+ + + + {run.status} + + + + {run.emails_fetched} + + + {run.emails_forwarded} + + + {run.emails_failed > 0 ? run.emails_failed : 0} + + + {formatDuration(run.duration_seconds)} + + + {expanded ? ( + + ) : ( + + )} + + + {expanded && ( + + + {run.error_message && ( +
+ {run.error_message} +
+ )} + {logsLoading ? ( +
+ Loading logs… +
+ ) : logsData && logsData.items.length > 0 ? ( + <> + + + + + + + + + + + + {logsData.items.map((log: ProcessingLog) => ( + + + + + + + + ))} + +
TimeLevelSubjectFromStatus
+ {formatDate(log.timestamp)} + + + {log.level} + + + {log.email_subject ?? '—'} + + {log.email_from ?? '—'} + + {log.success ? ( + + ) : ( + + )} +
+ {logsData.pages > 1 && ( +
+ + + Page {logsPage} of {logsData.pages} + + +
+ )} + + ) : ( +

No per-email logs recorded for this run.

+ )} + + + )} + + ); +} + +export default function LogsPage() { + const [page, setPage] = useState(1); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['processing-runs', page], + queryFn: () => processingRunsApi.list({ page, page_size: 20 }), + }); + + return ( + + +
+ {/* Header */} +
+
+

+ + Processing Logs +

+

+ History of email processing runs for all your mail accounts. +

+
+ +
+ + {/* Table */} + {isLoading ? ( +
+
+
+ ) : isError ? ( +
+ Failed to load processing logs. Please try again. +
+ ) : data && data.items.length > 0 ? ( +
+
+ + + + + + + + + + + + + + {data.items.map((run) => ( + + ))} + +
StartedAccountStatusFetchedForwardedFailedDuration +
+
+ + {/* Pagination */} + {data.pages > 1 && ( +
+ + Page {data.page} of {data.pages} ({data.total} runs) + +
+ + +
+
+ )} +
+ ) : ( +
+ +

No processing runs yet

+

+ Logs will appear here once your mail accounts start processing emails. +

+
+ )} +
+ + + ); +} From e5f359bbd9fb342ff2e718d1b0027e6231a861bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:11:17 +0000 Subject: [PATCH 5/5] fix: apply black formatting to alembic migration file Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/7d808813-2577-41cc-b90b-26cf974a22cb Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .../alembic/versions/0001_add_notification_columns.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/backend/alembic/versions/0001_add_notification_columns.py b/backend/alembic/versions/0001_add_notification_columns.py index ada474d..198f298 100644 --- a/backend/alembic/versions/0001_add_notification_columns.py +++ b/backend/alembic/versions/0001_add_notification_columns.py @@ -29,15 +29,10 @@ def upgrade() -> None: "ADD COLUMN IF NOT EXISTS name VARCHAR(255) NOT NULL DEFAULT 'My Notification'" ) op.execute( - "ALTER TABLE notification_configs " - "ADD COLUMN IF NOT EXISTS apprise_url TEXT" + "ALTER TABLE notification_configs " "ADD COLUMN IF NOT EXISTS apprise_url TEXT" ) def downgrade() -> None: - op.execute( - "ALTER TABLE notification_configs DROP COLUMN IF EXISTS apprise_url" - ) - op.execute( - "ALTER TABLE notification_configs DROP COLUMN IF EXISTS name" - ) + op.execute("ALTER TABLE notification_configs DROP COLUMN IF EXISTS apprise_url") + op.execute("ALTER TABLE notification_configs DROP COLUMN IF EXISTS name")