diff --git a/app/api/endpoints/opportunities.py b/app/api/endpoints/opportunities.py index 7ed055b..c815472 100644 --- a/app/api/endpoints/opportunities.py +++ b/app/api/endpoints/opportunities.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging from typing import Literal from fastapi import APIRouter, HTTPException @@ -10,6 +11,7 @@ from app.core.config import get_settings from app.db.store import Store router = APIRouter(prefix="/opportunities", tags=["opportunities"]) +logger = logging.getLogger(__name__) VALID_STATUSES = {"pending_review", "approved", "posted", "manual_posted", "dismissed"} @@ -74,10 +76,13 @@ async def list_opportunities(status: str | None = None): @router.post("", status_code=201) async def create_opportunity(body: OpportunityCreate): - return await asyncio.to_thread( + logger.info("Creating opportunity: platform=%s community=%s", body.platform, body.community) + result = await asyncio.to_thread( _in_thread, lambda s: s.create_opportunity(**body.model_dump()), ) + logger.info("Opportunity created: id=%s", result.get("id")) + return result @router.get("/{opportunity_id}") @@ -105,6 +110,7 @@ async def update_opportunity(opportunity_id: int, body: OpportunityUpdate): async def approve_opportunity(opportunity_id: int): """Mark as approved. For Reddit opportunities, returns auto-post instructions. For other platforms, returns a manual handoff payload.""" + logger.info("Approving opportunity id=%s", opportunity_id) opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id)) if opp is None: raise HTTPException(404, "Opportunity not found") @@ -136,6 +142,7 @@ async def approve_opportunity(opportunity_id: int): async def mark_posted(opportunity_id: int, body: MarkPostedBody = MarkPostedBody(), manual: bool = False): """Record that a post was successfully made (auto or manual). When manual=True, also writes a row to the posts table for history tracking.""" + logger.info("mark-posted: id=%s manual=%s url=%r", opportunity_id, manual, body.url) opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id)) if opp is None: raise HTTPException(404, "Opportunity not found") @@ -161,6 +168,7 @@ async def mark_posted(opportunity_id: int, body: MarkPostedBody = MarkPostedBody @router.post("/{opportunity_id}/dismiss") async def dismiss_opportunity(opportunity_id: int, body: DismissBody): + logger.info("Dismissing opportunity id=%s note=%r", opportunity_id, body.note) result = await asyncio.to_thread( _in_thread, lambda s: s.dismiss_opportunity(opportunity_id, body.note) ) diff --git a/app/api/endpoints/stats.py b/app/api/endpoints/stats.py new file mode 100644 index 0000000..3afeaed --- /dev/null +++ b/app/api/endpoints/stats.py @@ -0,0 +1,87 @@ +""" +Aggregate stats endpoint — counts across posts and opportunities. +Returns a single payload with everything the StatsBar needs. +""" +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter + +from app.core.config import get_settings +from app.db.store import Store + +router = APIRouter(prefix="/stats", tags=["stats"]) +logger = logging.getLogger(__name__) + + +def _fetch_stats(db_path: str) -> dict: + store = Store(db_path) + try: + # --- Post counts --- + # All-time by status + post_rows = store._fetchall( + "SELECT status, COUNT(*) AS n FROM posts GROUP BY status" + ) + posts_by_status: dict[str, int] = {r["status"]: r["n"] for r in post_rows} + + # Past 7 days total (any status) + posts_7d = store._fetchone( + "SELECT COUNT(*) AS n FROM posts WHERE posted_at >= datetime('now', '-7 days')" + ) + + # Top 5 communities by successful post count + top_communities = store._fetchall( + """ + SELECT target, COUNT(*) AS n + FROM posts + WHERE status = 'success' + GROUP BY target + ORDER BY n DESC + LIMIT 5 + """ + ) + + # Platform breakdown (success only) + platform_rows = store._fetchall( + "SELECT platform, COUNT(*) AS n FROM posts WHERE status = 'success' GROUP BY platform" + ) + + # --- Opportunity counts --- + opp_rows = store._fetchall( + "SELECT status, COUNT(*) AS n FROM opportunities GROUP BY status" + ) + opps_by_status: dict[str, int] = {r["status"]: r["n"] for r in opp_rows} + + return { + "posts": { + "total": sum(posts_by_status.values()), + "success": posts_by_status.get("success", 0), + "failed": posts_by_status.get("failed", 0), + "skipped": posts_by_status.get("skipped", 0), + "last_7_days": (posts_7d or {}).get("n", 0), + "by_platform": {r["platform"]: r["n"] for r in platform_rows}, + "top_communities": [ + {"community": r["target"], "count": r["n"]} for r in top_communities + ], + }, + "opportunities": { + "total": sum(opps_by_status.values()), + "pending_review": opps_by_status.get("pending_review", 0), + "approved": opps_by_status.get("approved", 0), + "posted": opps_by_status.get("posted", 0), + "manual_posted": opps_by_status.get("manual_posted", 0), + "dismissed": opps_by_status.get("dismissed", 0), + }, + } + finally: + store.close() + + +@router.get("") +async def get_stats() -> dict: + """Aggregate stats across posts and opportunities.""" + db_path = get_settings().db_path + logger.debug("Fetching aggregate stats") + return await asyncio.to_thread(_fetch_stats, db_path) diff --git a/app/api/routes.py b/app/api/routes.py index ed1ec4d..08373ad 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,6 +1,6 @@ from fastapi import FastAPI -from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, subs +from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, stats, subs def register_routes(app: FastAPI) -> None: @@ -12,3 +12,4 @@ def register_routes(app: FastAPI) -> None: app.include_router(signals.router, prefix="/api/v1") app.include_router(blog.router, prefix="/api/v1") app.include_router(reddit.router, prefix="/api/v1") + app.include_router(stats.router, prefix="/api/v1") diff --git a/app/core/logging_config.py b/app/core/logging_config.py new file mode 100644 index 0000000..1e6721d --- /dev/null +++ b/app/core/logging_config.py @@ -0,0 +1,32 @@ +""" +Logging configuration for Magpie. + +Call configure_logging() once at app startup (in lifespan). +Writes to stdout — uvicorn captures stdout to $LOG_API via manage.sh. +Format: timestamp [LEVEL] module: message +""" +from __future__ import annotations + +import logging +import sys + + +def configure_logging(level: str = "INFO") -> None: + """Apply a consistent log format across all loggers.""" + fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + datefmt = "%Y-%m-%d %H:%M:%S" + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) + + root = logging.getLogger() + # Avoid double-adding if called more than once (e.g. --reload) + if not root.handlers: + root.addHandler(handler) + root.setLevel(getattr(logging, level.upper(), logging.INFO)) + + # Quiet noisy third-party loggers + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + logging.getLogger("apscheduler").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) diff --git a/app/main.py b/app/main.py index 4e3ac40..b0ab2b8 100644 --- a/app/main.py +++ b/app/main.py @@ -3,17 +3,20 @@ from __future__ import annotations import logging from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from app.api.routes import register_routes from app.core.config import get_settings +from app.core.logging_config import configure_logging from app.db.store import Store from app.services.scheduler import ( start_scheduler, stop_scheduler, sync_all_campaigns, start_scraper_job, ) +configure_logging() logger = logging.getLogger(__name__) @@ -67,6 +70,19 @@ def create_app() -> FastAPI: allow_headers=["*"], ) register_routes(app) + + @app.exception_handler(Exception) + async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + logger.exception( + "Unhandled exception on %s %s", + request.method, + request.url.path, + ) + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"}, + ) + return app diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5f9e66c..a17253a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -22,9 +22,13 @@ + + + + @@ -50,3 +54,8 @@ + + diff --git a/frontend/src/components/CampaignDetail.vue b/frontend/src/components/CampaignDetail.vue index 5b985db..f2dd6a4 100644 --- a/frontend/src/components/CampaignDetail.vue +++ b/frontend/src/components/CampaignDetail.vue @@ -186,7 +186,9 @@ import { onMounted, reactive, ref } from 'vue' import { useRoute } from 'vue-router' import { api, type Campaign, type Variant, type CampaignSub, type Post, type SubRules } from '@/services/api' +import { useToast } from '../composables/useToast' +const toast = useToast() const route = useRoute() const campaignId = Number(route.params.id) @@ -206,18 +208,22 @@ const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '', const subForm = reactive({ sub: '' }) onMounted(async () => { - const [c, v, s, p, allRules] = await Promise.all([ - api.campaigns.get(campaignId), - api.variants.list(campaignId), - api.subs.listForCampaign(campaignId), - api.posts.list(campaignId, undefined, 20), - api.subs.listRules(), - ]) - campaign.value = c - variants.value = v - campaignSubs.value = s - recentPosts.value = p - subRulesMap.value = Object.fromEntries(allRules.map(r => [r.sub, r])) + try { + const [c, v, s, p, allRules] = await Promise.all([ + api.campaigns.get(campaignId), + api.variants.list(campaignId), + api.subs.listForCampaign(campaignId), + api.posts.list(campaignId, undefined, 20), + api.subs.listRules(), + ]) + campaign.value = c + variants.value = v + campaignSubs.value = s + recentPosts.value = p + subRulesMap.value = Object.fromEntries(allRules.map(r => [r.sub, r])) + } catch (e: unknown) { + toast.error(`Failed to load campaign: ${e instanceof Error ? e.message : 'Unknown error'}`) + } }) async function triggerSub(sub: string) { @@ -225,6 +231,8 @@ async function triggerSub(sub: string) { try { await api.posts.trigger(campaignId, sub) recentPosts.value = await api.posts.list(campaignId, undefined, 20) + } catch (e: unknown) { + toast.error(`Trigger failed for ${sub}: ${e instanceof Error ? e.message : 'Unknown error'}`) } finally { triggeringSub.value = null } @@ -235,39 +243,57 @@ async function triggerAll() { try { await api.campaigns.trigger(campaignId) recentPosts.value = await api.posts.list(campaignId, undefined, 20) + } catch (e: unknown) { + toast.error(`Trigger all failed: ${e instanceof Error ? e.message : 'Unknown error'}`) } finally { triggering.value = false } } async function addVariant() { - const v = await api.variants.create(campaignId, { - sub_pattern: variantForm.sub_pattern || '*', - title: variantForm.title, - body: variantForm.body, - flair: variantForm.flair || null, - notes: variantForm.notes || null, - }) - variants.value = [...variants.value, v] - showAddVariant.value = false - Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' }) + try { + const v = await api.variants.create(campaignId, { + sub_pattern: variantForm.sub_pattern || '*', + title: variantForm.title, + body: variantForm.body, + flair: variantForm.flair || null, + notes: variantForm.notes || null, + }) + variants.value = [...variants.value, v] + showAddVariant.value = false + Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' }) + } catch (e: unknown) { + toast.error(`Failed to add variant: ${e instanceof Error ? e.message : 'Unknown error'}`) + } } async function deleteVariant(id: number) { - await api.variants.delete(campaignId, id) - variants.value = variants.value.filter(v => v.id !== id) + try { + await api.variants.delete(campaignId, id) + variants.value = variants.value.filter(v => v.id !== id) + } catch (e: unknown) { + toast.error(`Failed to delete variant: ${e instanceof Error ? e.message : 'Unknown error'}`) + } } async function addSub() { - const s = await api.subs.add(campaignId, subForm.sub) - campaignSubs.value = [...campaignSubs.value, s] - showAddSub.value = false - subForm.sub = '' + try { + const s = await api.subs.add(campaignId, subForm.sub) + campaignSubs.value = [...campaignSubs.value, s] + showAddSub.value = false + subForm.sub = '' + } catch (e: unknown) { + toast.error(`Failed to add sub: ${e instanceof Error ? e.message : 'Unknown error'}`) + } } async function removeSub(sub: string) { - await api.subs.remove(campaignId, sub) - campaignSubs.value = campaignSubs.value.filter(s => s.sub !== sub) + try { + await api.subs.remove(campaignId, sub) + campaignSubs.value = campaignSubs.value.filter(s => s.sub !== sub) + } catch (e: unknown) { + toast.error(`Failed to remove ${sub}: ${e instanceof Error ? e.message : 'Unknown error'}`) + } } function resolveVariant(sub: string): Variant | null { diff --git a/frontend/src/components/OpportunitiesView.vue b/frontend/src/components/OpportunitiesView.vue index 73e388e..c4e77ac 100644 --- a/frontend/src/components/OpportunitiesView.vue +++ b/frontend/src/components/OpportunitiesView.vue @@ -222,6 +222,9 @@ + + diff --git a/frontend/src/components/ToastList.vue b/frontend/src/components/ToastList.vue new file mode 100644 index 0000000..1889bb9 --- /dev/null +++ b/frontend/src/components/ToastList.vue @@ -0,0 +1,77 @@ + + + + {{ icons[t.type] }} + {{ t.message }} + ✕ + + + + + + + diff --git a/frontend/src/composables/useToast.ts b/frontend/src/composables/useToast.ts new file mode 100644 index 0000000..3761229 --- /dev/null +++ b/frontend/src/composables/useToast.ts @@ -0,0 +1,30 @@ +import { ref } from 'vue' + +export type ToastType = 'error' | 'success' | 'info' + +export interface Toast { + id: number + type: ToastType + message: string +} + +let _nextId = 1 +const toasts = ref([]) + +export function useToast() { + function show(message: string, type: ToastType = 'info', durationMs = 4000) { + const id = _nextId++ + toasts.value = [...toasts.value, { id, type, message }] + setTimeout(() => dismiss(id), durationMs) + } + + function dismiss(id: number) { + toasts.value = toasts.value.filter(t => t.id !== id) + } + + const error = (msg: string) => show(msg, 'error', 6000) + const success = (msg: string) => show(msg, 'success', 3000) + const info = (msg: string) => show(msg, 'info', 4000) + + return { toasts, show, dismiss, error, success, info } +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 02e7832..d884278 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -311,4 +311,6 @@ export const api = { updateStatus: (id: number, status: SignalStatus, notes?: string) => http.patch(`/signals/${id}/status`, { status, notes: notes ?? null }).then(r => r.data), }, + + stats: () => http.get('/stats').then(r => r.data), }