#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:
parent
c2f036ab21
commit
a863960266
12 changed files with 462 additions and 36 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
@ -10,6 +11,7 @@ from app.core.config import get_settings
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
router = APIRouter(prefix="/opportunities", tags=["opportunities"])
|
router = APIRouter(prefix="/opportunities", tags=["opportunities"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
VALID_STATUSES = {"pending_review", "approved", "posted", "manual_posted", "dismissed"}
|
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)
|
@router.post("", status_code=201)
|
||||||
async def create_opportunity(body: OpportunityCreate):
|
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,
|
_in_thread,
|
||||||
lambda s: s.create_opportunity(**body.model_dump()),
|
lambda s: s.create_opportunity(**body.model_dump()),
|
||||||
)
|
)
|
||||||
|
logger.info("Opportunity created: id=%s", result.get("id"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{opportunity_id}")
|
@router.get("/{opportunity_id}")
|
||||||
|
|
@ -105,6 +110,7 @@ async def update_opportunity(opportunity_id: int, body: OpportunityUpdate):
|
||||||
async def approve_opportunity(opportunity_id: int):
|
async def approve_opportunity(opportunity_id: int):
|
||||||
"""Mark as approved. For Reddit opportunities, returns auto-post instructions.
|
"""Mark as approved. For Reddit opportunities, returns auto-post instructions.
|
||||||
For other platforms, returns a manual handoff payload."""
|
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))
|
opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
|
||||||
if opp is None:
|
if opp is None:
|
||||||
raise HTTPException(404, "Opportunity not found")
|
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):
|
async def mark_posted(opportunity_id: int, body: MarkPostedBody = MarkPostedBody(), manual: bool = False):
|
||||||
"""Record that a post was successfully made (auto or manual).
|
"""Record that a post was successfully made (auto or manual).
|
||||||
When manual=True, also writes a row to the posts table for history tracking."""
|
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))
|
opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
|
||||||
if opp is None:
|
if opp is None:
|
||||||
raise HTTPException(404, "Opportunity not found")
|
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")
|
@router.post("/{opportunity_id}/dismiss")
|
||||||
async def dismiss_opportunity(opportunity_id: int, body: DismissBody):
|
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(
|
result = await asyncio.to_thread(
|
||||||
_in_thread, lambda s: s.dismiss_opportunity(opportunity_id, body.note)
|
_in_thread, lambda s: s.dismiss_opportunity(opportunity_id, body.note)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
87
app/api/endpoints/stats.py
Normal file
87
app/api/endpoints/stats.py
Normal 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)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from fastapi import FastAPI
|
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:
|
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(signals.router, prefix="/api/v1")
|
||||||
app.include_router(blog.router, prefix="/api/v1")
|
app.include_router(blog.router, prefix="/api/v1")
|
||||||
app.include_router(reddit.router, prefix="/api/v1")
|
app.include_router(reddit.router, prefix="/api/v1")
|
||||||
|
app.include_router(stats.router, prefix="/api/v1")
|
||||||
|
|
|
||||||
32
app/core/logging_config.py
Normal file
32
app/core/logging_config.py
Normal 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)
|
||||||
18
app/main.py
18
app/main.py
|
|
@ -3,17 +3,20 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.api.routes import register_routes
|
from app.api.routes import register_routes
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging_config import configure_logging
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.services.scheduler import (
|
from app.services.scheduler import (
|
||||||
start_scheduler, stop_scheduler, sync_all_campaigns,
|
start_scheduler, stop_scheduler, sync_all_campaigns,
|
||||||
start_scraper_job,
|
start_scraper_job,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -67,6 +70,19 @@ def create_app() -> FastAPI:
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
register_routes(app)
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,13 @@
|
||||||
|
|
||||||
<!-- Main -->
|
<!-- Main -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
|
<StatsBar />
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Global toast notifications -->
|
||||||
|
<ToastList />
|
||||||
|
|
||||||
<!-- Bottom Nav (mobile <768px) -->
|
<!-- Bottom Nav (mobile <768px) -->
|
||||||
<nav class="bottom-nav" aria-label="Main navigation">
|
<nav class="bottom-nav" aria-label="Main navigation">
|
||||||
<router-link class="bottom-nav-item" to="/signals" active-class="active">
|
<router-link class="bottom-nav-item" to="/signals" active-class="active">
|
||||||
|
|
@ -50,3 +54,8 @@
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ToastList from './components/ToastList.vue'
|
||||||
|
import StatsBar from './components/StatsBar.vue'
|
||||||
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,9 @@
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { api, type Campaign, type Variant, type CampaignSub, type Post, type SubRules } from '@/services/api'
|
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 route = useRoute()
|
||||||
const campaignId = Number(route.params.id)
|
const campaignId = Number(route.params.id)
|
||||||
|
|
||||||
|
|
@ -206,6 +208,7 @@ const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '',
|
||||||
const subForm = reactive({ sub: '' })
|
const subForm = reactive({ sub: '' })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
const [c, v, s, p, allRules] = await Promise.all([
|
const [c, v, s, p, allRules] = await Promise.all([
|
||||||
api.campaigns.get(campaignId),
|
api.campaigns.get(campaignId),
|
||||||
api.variants.list(campaignId),
|
api.variants.list(campaignId),
|
||||||
|
|
@ -218,6 +221,9 @@ onMounted(async () => {
|
||||||
campaignSubs.value = s
|
campaignSubs.value = s
|
||||||
recentPosts.value = p
|
recentPosts.value = p
|
||||||
subRulesMap.value = Object.fromEntries(allRules.map(r => [r.sub, r]))
|
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) {
|
async function triggerSub(sub: string) {
|
||||||
|
|
@ -225,6 +231,8 @@ async function triggerSub(sub: string) {
|
||||||
try {
|
try {
|
||||||
await api.posts.trigger(campaignId, sub)
|
await api.posts.trigger(campaignId, sub)
|
||||||
recentPosts.value = await api.posts.list(campaignId, undefined, 20)
|
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 {
|
} finally {
|
||||||
triggeringSub.value = null
|
triggeringSub.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -235,12 +243,15 @@ async function triggerAll() {
|
||||||
try {
|
try {
|
||||||
await api.campaigns.trigger(campaignId)
|
await api.campaigns.trigger(campaignId)
|
||||||
recentPosts.value = await api.posts.list(campaignId, undefined, 20)
|
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 {
|
} finally {
|
||||||
triggering.value = false
|
triggering.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addVariant() {
|
async function addVariant() {
|
||||||
|
try {
|
||||||
const v = await api.variants.create(campaignId, {
|
const v = await api.variants.create(campaignId, {
|
||||||
sub_pattern: variantForm.sub_pattern || '*',
|
sub_pattern: variantForm.sub_pattern || '*',
|
||||||
title: variantForm.title,
|
title: variantForm.title,
|
||||||
|
|
@ -251,23 +262,38 @@ async function addVariant() {
|
||||||
variants.value = [...variants.value, v]
|
variants.value = [...variants.value, v]
|
||||||
showAddVariant.value = false
|
showAddVariant.value = false
|
||||||
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
|
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) {
|
async function deleteVariant(id: number) {
|
||||||
|
try {
|
||||||
await api.variants.delete(campaignId, id)
|
await api.variants.delete(campaignId, id)
|
||||||
variants.value = variants.value.filter(v => v.id !== 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() {
|
async function addSub() {
|
||||||
|
try {
|
||||||
const s = await api.subs.add(campaignId, subForm.sub)
|
const s = await api.subs.add(campaignId, subForm.sub)
|
||||||
campaignSubs.value = [...campaignSubs.value, s]
|
campaignSubs.value = [...campaignSubs.value, s]
|
||||||
showAddSub.value = false
|
showAddSub.value = false
|
||||||
subForm.sub = ''
|
subForm.sub = ''
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(`Failed to add sub: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeSub(sub: string) {
|
async function removeSub(sub: string) {
|
||||||
|
try {
|
||||||
await api.subs.remove(campaignId, sub)
|
await api.subs.remove(campaignId, sub)
|
||||||
campaignSubs.value = campaignSubs.value.filter(s => s.sub !== 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 {
|
function resolveVariant(sub: string): Variant | null {
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { api, type Opportunity, type OpportunityStatus } from '../services/api'
|
import { api, type Opportunity, type OpportunityStatus } from '../services/api'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const opportunities = ref<Opportunity[]>([])
|
const opportunities = ref<Opportunity[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
@ -290,6 +293,8 @@ async function saveDraft() {
|
||||||
draft_title: editTitle.value || undefined,
|
draft_title: editTitle.value || undefined,
|
||||||
})
|
})
|
||||||
replace(updated)
|
replace(updated)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(`Failed to save draft: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +310,8 @@ async function approve() {
|
||||||
}
|
}
|
||||||
const result = await api.opportunities.approve(selected.value.id)
|
const result = await api.opportunities.approve(selected.value.id)
|
||||||
replace(result.opportunity)
|
replace(result.opportunity)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(`Failed to approve: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -316,6 +323,8 @@ async function dismiss() {
|
||||||
try {
|
try {
|
||||||
const updated = await api.opportunities.dismiss(selected.value.id)
|
const updated = await api.opportunities.dismiss(selected.value.id)
|
||||||
replace(updated)
|
replace(updated)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(`Failed to dismiss: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -329,6 +338,8 @@ async function markManualPosted() {
|
||||||
replace(updated)
|
replace(updated)
|
||||||
confirmingPosted.value = false
|
confirmingPosted.value = false
|
||||||
postedUrl.value = ''
|
postedUrl.value = ''
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(`Failed to mark as posted: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -336,9 +347,13 @@ async function markManualPosted() {
|
||||||
|
|
||||||
async function copyDraft() {
|
async function copyDraft() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
|
try {
|
||||||
await navigator.clipboard.writeText(selected.value.draft_body)
|
await navigator.clipboard.writeText(selected.value.draft_body)
|
||||||
copied.value = true
|
copied.value = true
|
||||||
setTimeout(() => { copied.value = false }, 2000)
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not copy to clipboard')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOpp() {
|
async function createOpp() {
|
||||||
|
|
@ -353,6 +368,8 @@ async function createOpp() {
|
||||||
opportunities.value.unshift(created)
|
opportunities.value.unshift(created)
|
||||||
showAddModal.value = false
|
showAddModal.value = false
|
||||||
newOpp.value = { platform: 'reddit', community: '', thread_url: '', thread_title: '', signal_reason: '', product: '', draft_body: '', post_type: 'reply_to_thread' }
|
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 {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
frontend/src/components/StatsBar.vue
Normal file
121
frontend/src/components/StatsBar.vue
Normal 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>
|
||||||
77
frontend/src/components/ToastList.vue
Normal file
77
frontend/src/components/ToastList.vue
Normal 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>
|
||||||
30
frontend/src/composables/useToast.ts
Normal file
30
frontend/src/composables/useToast.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -311,4 +311,6 @@ export const api = {
|
||||||
updateStatus: (id: number, status: SignalStatus, notes?: string) =>
|
updateStatus: (id: number, status: SignalStatus, notes?: string) =>
|
||||||
http.patch<Signal>(`/signals/${id}/status`, { status, notes: notes ?? null }).then(r => r.data),
|
http.patch<Signal>(`/signals/${id}/status`, { status, notes: notes ?? null }).then(r => r.data),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
stats: () => http.get('/stats').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue