feat: structured logging, frontend error toasts, stats bar (#14 #15 #16)

#14 — Structured logging
- app/core/logging_config.py: configure_logging() sets stdout handler
  with timestamped format; called at import time in main.py
- Global FastAPI exception_handler logs 500s with full traceback
- opportunities.py: logger added; create/approve/mark-posted/dismiss
  each emit an info line so failures are traceable

#15 — Frontend error handling
- frontend/src/composables/useToast.ts: shared toast composable
  (error/success/info, auto-dismiss, module-level singleton)
- frontend/src/components/ToastList.vue: fixed-position overlay,
  theme-aware, accessible (role=alert, aria-live=polite)
- OpportunitiesView: all 6 async actions have catch + toast.error()
- CampaignDetail: onMounted + all 6 mutation functions wrapped

#16 — Aggregate stats
- app/api/endpoints/stats.py: GET /api/v1/stats — single DB pass
  via GROUP BY; returns posts totals, 7-day count, top communities,
  platform breakdown, and opportunity queue counts
- frontend/src/components/StatsBar.vue: slim header bar above
  router-view; chips for posts ok/failed/week, queue pending/approved/
  posted, top community; hides gracefully on API error
This commit is contained in:
Alan Weinstock 2026-05-25 15:02:15 -07:00
parent c2f036ab21
commit a863960266
12 changed files with 462 additions and 36 deletions

View file

@ -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)
)

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -22,9 +22,13 @@
<!-- Main -->
<main class="main-content">
<StatsBar />
<router-view />
</main>
<!-- Global toast notifications -->
<ToastList />
<!-- Bottom Nav (mobile <768px) -->
<nav class="bottom-nav" aria-label="Main navigation">
<router-link class="bottom-nav-item" to="/signals" active-class="active">
@ -50,3 +54,8 @@
</nav>
</div>
</template>
<script setup lang="ts">
import ToastList from './components/ToastList.vue'
import StatsBar from './components/StatsBar.vue'
</script>

View file

@ -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 {

View file

@ -222,6 +222,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { api, type Opportunity, type OpportunityStatus } from '../services/api'
import { useToast } from '../composables/useToast'
const toast = useToast()
const opportunities = ref<Opportunity[]>([])
const loading = ref(false)
@ -290,6 +293,8 @@ async function saveDraft() {
draft_title: editTitle.value || undefined,
})
replace(updated)
} catch (e: unknown) {
toast.error(`Failed to save draft: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally {
saving.value = false
}
@ -305,6 +310,8 @@ async function approve() {
}
const result = await api.opportunities.approve(selected.value.id)
replace(result.opportunity)
} catch (e: unknown) {
toast.error(`Failed to approve: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally {
saving.value = false
}
@ -316,6 +323,8 @@ async function dismiss() {
try {
const updated = await api.opportunities.dismiss(selected.value.id)
replace(updated)
} catch (e: unknown) {
toast.error(`Failed to dismiss: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally {
saving.value = false
}
@ -329,6 +338,8 @@ async function markManualPosted() {
replace(updated)
confirmingPosted.value = false
postedUrl.value = ''
} catch (e: unknown) {
toast.error(`Failed to mark as posted: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally {
saving.value = false
}
@ -336,9 +347,13 @@ async function markManualPosted() {
async function copyDraft() {
if (!selected.value) return
await navigator.clipboard.writeText(selected.value.draft_body)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
try {
await navigator.clipboard.writeText(selected.value.draft_body)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
toast.error('Could not copy to clipboard')
}
}
async function createOpp() {
@ -353,6 +368,8 @@ async function createOpp() {
opportunities.value.unshift(created)
showAddModal.value = false
newOpp.value = { platform: 'reddit', community: '', thread_url: '', thread_title: '', signal_reason: '', product: '', draft_body: '', post_type: 'reply_to_thread' }
} catch (e: unknown) {
toast.error(`Failed to create opportunity: ${e instanceof Error ? e.message : 'Unknown error'}`)
} finally {
saving.value = false
}

View file

@ -0,0 +1,121 @@
<template>
<div v-if="stats" class="stats-bar" aria-label="Activity summary">
<div class="stats-group">
<span class="stats-label">Posts</span>
<span class="stats-chip stats-chip--success">{{ stats.posts.success }} ok</span>
<span v-if="stats.posts.failed" class="stats-chip stats-chip--danger">{{ stats.posts.failed }} failed</span>
<span class="stats-chip">{{ stats.posts.last_7_days }} this week</span>
</div>
<div class="stats-sep" aria-hidden="true">|</div>
<div class="stats-group">
<span class="stats-label">Queue</span>
<span v-if="stats.opportunities.pending_review" class="stats-chip stats-chip--warn">
{{ stats.opportunities.pending_review }} pending
</span>
<span v-if="stats.opportunities.approved" class="stats-chip stats-chip--accent">
{{ stats.opportunities.approved }} approved
</span>
<span class="stats-chip">{{ stats.opportunities.posted + stats.opportunities.manual_posted }} posted</span>
</div>
<div v-if="topCommunity" class="stats-sep" aria-hidden="true">|</div>
<div v-if="topCommunity" class="stats-group stats-group--community">
<span class="stats-label">Top</span>
<span class="stats-chip">{{ topCommunity.community }} ({{ topCommunity.count }})</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { api } from '../services/api'
interface StatsPayload {
posts: {
total: number
success: number
failed: number
skipped: number
last_7_days: number
by_platform: Record<string, number>
top_communities: { community: string; count: number }[]
}
opportunities: {
total: number
pending_review: number
approved: number
posted: number
manual_posted: number
dismissed: number
}
}
const stats = ref<StatsPayload | null>(null)
const topCommunity = computed(() =>
stats.value?.posts.top_communities[0] ?? null
)
onMounted(async () => {
try {
stats.value = await api.stats()
} catch {
// Non-critical stats bar is informational; fail silently
}
})
</script>
<style scoped>
.stats-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 6px 16px;
background: var(--color-bg-card);
border-bottom: 1px solid var(--color-border);
font-size: 0.78rem;
color: var(--color-text-muted, #888);
min-height: 36px;
}
.stats-group {
display: flex;
align-items: center;
gap: 6px;
}
.stats-group--community {
/* hide on very narrow screens */
}
.stats-label {
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
}
.stats-sep {
color: var(--color-border);
user-select: none;
}
.stats-chip {
padding: 2px 8px;
border-radius: 12px;
background: var(--color-bg, #1e1e1e);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
white-space: nowrap;
}
.stats-chip--success { border-color: var(--color-success, #4caf7d); color: var(--color-success, #4caf7d); }
.stats-chip--danger { border-color: var(--color-danger, #e05252); color: var(--color-danger, #e05252); }
.stats-chip--warn { border-color: var(--color-warn, #d4a017); color: var(--color-warn, #d4a017); }
.stats-chip--accent { border-color: var(--color-accent, #5b7fa6); color: var(--color-accent, #5b7fa6); }
@media (max-width: 480px) {
.stats-group--community { display: none; }
.stats-sep:last-of-type { display: none; }
}
</style>

View file

@ -0,0 +1,77 @@
<template>
<div class="toast-list" aria-live="polite" aria-atomic="false">
<div
v-for="t in toasts"
:key="t.id"
:class="['toast', `toast--${t.type}`]"
role="alert"
>
<span class="toast-icon">{{ icons[t.type] }}</span>
<span class="toast-message">{{ t.message }}</span>
<button class="toast-close" @click="dismiss(t.id)" aria-label="Dismiss"></button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from '../composables/useToast'
const { toasts, dismiss } = useToast()
const icons = { error: '⚠️', success: '✓', info: '' }
</script>
<style scoped>
.toast-list {
position: fixed;
bottom: calc(var(--bottom-nav-height, 60px) + 12px);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
width: min(420px, 92vw);
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: 8px;
font-size: 0.875rem;
line-height: 1.4;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
pointer-events: all;
color: var(--color-text);
background: var(--color-bg-card);
border-left: 4px solid var(--color-border);
}
.toast--error { border-color: var(--color-danger, #e05252); }
.toast--success{ border-color: var(--color-success, #4caf7d); }
.toast--info { border-color: var(--color-accent, #5b7fa6); }
.toast-icon { flex-shrink: 0; font-size: 1rem; }
.toast-message { flex: 1; }
.toast-close {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted, #888);
font-size: 0.8rem;
padding: 0 2px;
line-height: 1;
}
.toast-close:hover { color: var(--color-text); }
@media (min-width: 768px) {
.toast-list {
bottom: 20px;
left: unset;
right: 20px;
transform: none;
}
}
</style>

View file

@ -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<Toast[]>([])
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 }
}

View file

@ -311,4 +311,6 @@ export const api = {
updateStatus: (id: number, status: SignalStatus, notes?: string) =>
http.patch<Signal>(`/signals/${id}/status`, { status, notes: notes ?? null }).then(r => r.data),
},
stats: () => http.get('/stats').then(r => r.data),
}