From 842e8063ddfa58c2fe900f4ce893700e4194642b Mon Sep 17 00:00:00 2001 From: JingWen Fan <106414602+study8677@users.noreply.github.com> Date: Sat, 30 May 2026 22:51:22 +0800 Subject: [PATCH 1/2] feat(ui): polish loading, error, and feedback states across the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Skeleton component family and Toast notification system, then applies them across the high-traffic pages so the app stops feeling empty during loads and silent during actions. What changes the user actually sees: - Loading: Chat, SEO, GEO, SERP, Community, Approvals, Graph, and ProjectMonitors now show layout-shaped skeletons instead of a blank spinner — no more flash of nothing. - Success: approving/rejecting content, adding/removing keywords, and creating/deleting monitors all surface confirmation toasts. Session expiry surfaces a toast with a "Sign in again" action. - Errors: ErrorAlert now supports a recovery hint, a Try-again button, optional error code, and a custom action — used on the Approvals query failure path. - Knowledge graph: container is now fluid-height (was hardcoded 600px), legend collapses on mobile, a one-tap "fit view" button helps touch users re-center after they get lost dragging. - Buttons: every disabled:opacity-50 now also has disabled:cursor-not-allowed so disabled buttons stop feeling stuck. - Tablet: KPI grids on SEO/GEO/SERP/Community now use md:grid-cols-3 instead of jumping straight from 2 → 4 columns and squishing at 800px. i18n: new toast.* and common.* keys added across en/zh/ja/ko/es. Also fixes two pre-existing leaks touched by this pass: - KnowledgeGraph's zoomToFit setTimeout is now cancelled in cleanup so rapid graphData updates don't pile up camera snaps. - Toast stack-overflow drops now clear their auto-dismiss timers. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/auth/AuthProvider.tsx | 23 +- frontend/src/components/auth/TokenPrompt.tsx | 2 +- .../src/components/charts/CompetitorPanel.tsx | 2 +- .../components/charts/ExpansionControls.tsx | 6 +- .../src/components/charts/KnowledgeGraph.tsx | 125 ++++++-- frontend/src/components/chat/ChatInput.tsx | 2 +- frontend/src/components/common/ErrorAlert.tsx | 123 +++++++- frontend/src/components/common/Skeleton.tsx | 287 ++++++++++++++++++ frontend/src/components/common/Toast.tsx | 251 +++++++++++++++ .../src/components/dashboard/WelcomeHero.tsx | 2 +- .../components/keywords/AddKeywordForm.tsx | 2 +- .../src/components/monitors/MonitorForm.tsx | 2 +- .../components/monitors/RunResultsDialog.tsx | 2 +- .../src/components/monitors/RunScanButton.tsx | 6 +- .../src/components/project/ActionFeed.tsx | 2 +- .../components/project/BlogGenerateButton.tsx | 4 +- .../components/settings/SettingsDialog.tsx | 2 +- frontend/src/i18n/locales/en.ts | 21 ++ frontend/src/i18n/locales/es.ts | 21 ++ frontend/src/i18n/locales/ja.ts | 21 ++ frontend/src/i18n/locales/ko.ts | 21 ++ frontend/src/i18n/locales/zh.ts | 21 ++ frontend/src/main.tsx | 9 +- frontend/src/pages/AdminPage.tsx | 8 +- frontend/src/pages/ApprovalsPage.tsx | 29 +- frontend/src/pages/ChatPage.tsx | 4 +- frontend/src/pages/CommunityPage.tsx | 6 +- frontend/src/pages/GeoPage.tsx | 11 +- frontend/src/pages/GitHubLeadsPage.tsx | 4 +- frontend/src/pages/GraphPage.tsx | 6 +- frontend/src/pages/PerformancePage.tsx | 2 +- frontend/src/pages/ProjectMonitorsPage.tsx | 40 ++- frontend/src/pages/SeoPage.tsx | 15 +- frontend/src/pages/SerpPage.tsx | 22 +- 34 files changed, 1012 insertions(+), 92 deletions(-) create mode 100644 frontend/src/components/common/Skeleton.tsx create mode 100644 frontend/src/components/common/Toast.tsx diff --git a/frontend/src/components/auth/AuthProvider.tsx b/frontend/src/components/auth/AuthProvider.tsx index dcad583..7dc29e6 100644 --- a/frontend/src/components/auth/AuthProvider.tsx +++ b/frontend/src/components/auth/AuthProvider.tsx @@ -3,11 +3,14 @@ import { useState, useEffect, useCallback, + useRef, type ReactNode, } from "react"; import { useQueryClient } from "@tanstack/react-query"; import * as authApi from "../../api/auth"; import type { AccountUsage, AuthAccount, AuthUser } from "../../types"; +import { useToast } from "../common/Toast"; +import { useI18n } from "../../i18n"; export type SignupOutcome = | { ok: true; needsVerification: true; userId: number; email: string } @@ -53,6 +56,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [account, setAccount] = useState(null); const [usage, setUsage] = useState(null); + const userRef = useRef(null); + useEffect(() => { + userRef.current = user; + }, [user]); const applyPayload = useCallback((payload: authApi.AuthPayload | { authenticated: false }) => { if (!payload.authenticated) { @@ -75,17 +82,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [applyPayload]); + const toast = useToast(); + const { t } = useI18n(); + // Listen for 401 events from apiFetch useEffect(() => { const handler = () => { + const wasAuthed = userRef.current != null; setUser(null); setAccount(null); setUsage(null); queryClient.cancelQueries(); + if (wasAuthed) { + toast.error(t("common.sessionExpired"), { + action: { + label: t("toast.signInAgain"), + onClick: () => { + window.location.href = "/login"; + }, + }, + }); + } }; window.addEventListener("opencmo:unauthorized", handler); return () => window.removeEventListener("opencmo:unauthorized", handler); - }, [queryClient]); + }, [queryClient, t, toast]); useEffect(() => { void refresh(); diff --git a/frontend/src/components/auth/TokenPrompt.tsx b/frontend/src/components/auth/TokenPrompt.tsx index a663ae8..c1ae4b6 100644 --- a/frontend/src/components/auth/TokenPrompt.tsx +++ b/frontend/src/components/auth/TokenPrompt.tsx @@ -40,7 +40,7 @@ export function TokenPrompt() { +
+ {Object.entries(NODE_COLORS_CSS).map(([type, color]) => ( +
+ + + {typeLabels[type] ?? type} + +
+ ))} +
- {/* Controls hint */} -
+ {/* "Fit view" button — drag tends to lose users on mobile; one tap recenters. */} + + + {/* Controls hint — hidden on mobile (touch users don't have a mouse) */} +
🖱 {t("graph.controlsHint")}
- {/* 3D Graph container */} -
+ {/* 3D Graph container — fluid height so it fits any viewport, including phones. */} +
+ )} + {action} +
+ )} +
+ {onDismiss && ( + + )}
); } diff --git a/frontend/src/components/common/Skeleton.tsx b/frontend/src/components/common/Skeleton.tsx new file mode 100644 index 0000000..487bfa1 --- /dev/null +++ b/frontend/src/components/common/Skeleton.tsx @@ -0,0 +1,287 @@ +import { motion } from "framer-motion"; +import type { ReactNode } from "react"; + +const SHIMMER = "animate-pulse bg-slate-100"; + +interface BaseProps { + className?: string; + delay?: number; +} + +/** Single rounded rectangle. Use as a Lego block for any skeleton. */ +export function SkeletonBlock({ className = "", delay = 0 }: BaseProps) { + return ( +
+ ); +} + +/** Text line: 1em high, configurable width. Mimics a real line of copy. */ +export function SkeletonText({ + width = "100%", + className = "", + delay = 0, +}: BaseProps & { width?: string }) { + return ( +
+ ); +} + +/** Small chip (KPI badge / tag). */ +export function SkeletonChip({ className = "", delay = 0 }: BaseProps) { + return ( +
+ ); +} + +/** Wrapper that mounts skeletons with a soft fade-in. Reusable shell. */ +export function SkeletonFrame({ + children, + className = "", +}: { + children: ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} + +/** A KPI card skeleton matching `KpiCard`'s real layout. */ +export function KpiCardSkeleton({ delay = 0 }: { delay?: number }) { + return ( +
+
+
+ + + +
+
+
+
+ ); +} + +/** A horizontal KPI row: 4 cards by default, responsive. */ +export function KpiRowSkeleton({ count = 4 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} + +/** A chart card skeleton — title + chart area. Matches `ChartCard` proportions. */ +export function ChartCardSkeleton({ height = 280 }: { height?: number }) { + return ( +
+
+
+ + +
+ +
+
+
+ ); +} + +/** A list-row skeleton (for tables, item lists). */ +export function ListRowSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+
+
+ + +
+ +
+ ))} +
+ ); +} + +/** Project header skeleton — matches `ProjectHeader` (avatar + name + tabs). */ +export function ProjectHeaderSkeleton() { + return ( +
+
+
+
+ + +
+
+
+ ); +} + +/** Full-page skeleton for project sub-pages (SEO, GEO, SERP, Community). + * Header → KPI row → 2 chart cards. + */ +export function ProjectSubpageSkeleton() { + return ( + + + +
+ + +
+
+ ); +} + +/** Dashboard skeleton — hero, KPI row, project grid. */ +export function DashboardSkeleton() { + return ( + +
+
+ + + +
+
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ); +} + +function ListItemCardSkeleton({ delay = 0 }: { delay?: number }) { + return ( +
+
+
+
+ + +
+
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ ); +} + +/** Chat shell skeleton — sidebar + chat area. */ +export function ChatSkeleton() { + return ( + +
+ + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ ))} +
+
+
+ ); +} + +/** Graph page skeleton — toolbar + large 3D area placeholder. */ +export function GraphSkeleton() { + return ( + +
+ + + +
+ +
+
+ + ); +} + +/** Approval-queue skeleton — single hero card. */ +export function ApprovalSkeleton() { + return ( + +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ + ); +} diff --git a/frontend/src/components/common/Toast.tsx b/frontend/src/components/common/Toast.tsx new file mode 100644 index 0000000..a2f563d --- /dev/null +++ b/frontend/src/components/common/Toast.tsx @@ -0,0 +1,251 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckCircle2, AlertCircle, Info, X, Loader2 } from "lucide-react"; + +export type ToastKind = "success" | "error" | "info" | "loading"; + +export interface ToastOptions { + /** Auto-dismiss after N ms. 0 or undefined → sticky (loading default). */ + duration?: number; + /** Show a small action button (e.g. "Undo", "Retry"). */ + action?: { label: string; onClick: () => void }; +} + +interface ToastRecord { + id: number; + kind: ToastKind; + message: string; + duration: number; + action?: ToastOptions["action"]; +} + +interface ToastApi { + show: (kind: ToastKind, message: string, opts?: ToastOptions) => number; + success: (message: string, opts?: ToastOptions) => number; + error: (message: string, opts?: ToastOptions) => number; + info: (message: string, opts?: ToastOptions) => number; + /** Returns the toast id so the caller can dismiss/update it. */ + loading: (message: string, opts?: ToastOptions) => number; + dismiss: (id: number) => void; + /** Update an existing toast in-place (useful: loading → success). */ + update: (id: number, kind: ToastKind, message: string, opts?: ToastOptions) => void; +} + +const ToastContext = createContext(null); + +const DEFAULT_DURATION: Record = { + success: 3200, + error: 5200, + info: 3600, + loading: 0, +}; + +const PALETTE: Record< + ToastKind, + { bg: string; border: string; iconBg: string; iconColor: string; text: string } +> = { + success: { + bg: "bg-white", + border: "border-emerald-200", + iconBg: "bg-emerald-50", + iconColor: "text-emerald-600", + text: "text-slate-900", + }, + error: { + bg: "bg-white", + border: "border-rose-200", + iconBg: "bg-rose-50", + iconColor: "text-rose-600", + text: "text-slate-900", + }, + info: { + bg: "bg-white", + border: "border-slate-200", + iconBg: "bg-slate-100", + iconColor: "text-slate-600", + text: "text-slate-900", + }, + loading: { + bg: "bg-white", + border: "border-indigo-200", + iconBg: "bg-indigo-50", + iconColor: "text-indigo-600", + text: "text-slate-900", + }, +}; + +function ToastIcon({ kind }: { kind: ToastKind }) { + const cls = `h-4 w-4 ${PALETTE[kind].iconColor}`; + if (kind === "success") return ; + if (kind === "error") return ; + if (kind === "loading") return ; + return ; +} + +function ToastItem({ toast, onDismiss }: { toast: ToastRecord; onDismiss: (id: number) => void }) { + const p = PALETTE[toast.kind]; + return ( + +
+ +
+
+

{toast.message}

+ {toast.action && ( + + )} +
+ +
+ ); +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const counterRef = useRef(0); + const timersRef = useRef>>(new Map()); + + const dismiss = useCallback((id: number) => { + setToasts((curr) => curr.filter((t) => t.id !== id)); + const timer = timersRef.current.get(id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(id); + } + }, []); + + const scheduleDismiss = useCallback( + (id: number, duration: number) => { + if (duration <= 0) return; + const prev = timersRef.current.get(id); + if (prev) clearTimeout(prev); + const handle = setTimeout(() => dismiss(id), duration); + timersRef.current.set(id, handle); + }, + [dismiss], + ); + + const show = useCallback( + (kind: ToastKind, message: string, opts?: ToastOptions): number => { + const id = ++counterRef.current; + const duration = opts?.duration ?? DEFAULT_DURATION[kind]; + const record: ToastRecord = { id, kind, message, duration, action: opts?.action }; + setToasts((curr) => { + // Cap stack at 5; drop the oldest. Clear their timers too so we don't + // leak setTimeout callbacks that fire dismiss() for gone-from-state ids. + const next = [...curr, record]; + if (next.length <= 5) return next; + const dropped = next.slice(0, next.length - 5); + for (const d of dropped) { + const timer = timersRef.current.get(d.id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(d.id); + } + } + return next.slice(next.length - 5); + }); + scheduleDismiss(id, duration); + return id; + }, + [scheduleDismiss], + ); + + const update = useCallback( + (id: number, kind: ToastKind, message: string, opts?: ToastOptions) => { + const duration = opts?.duration ?? DEFAULT_DURATION[kind]; + setToasts((curr) => + curr.map((t) => + t.id === id + ? { ...t, kind, message, duration, action: opts?.action ?? t.action } + : t, + ), + ); + scheduleDismiss(id, duration); + }, + [scheduleDismiss], + ); + + const api = useMemo( + () => ({ + show, + success: (m, o) => show("success", m, o), + error: (m, o) => show("error", m, o), + info: (m, o) => show("info", m, o), + loading: (m, o) => show("loading", m, o), + dismiss, + update, + }), + [show, dismiss, update], + ); + + useEffect(() => { + return () => { + timersRef.current.forEach((t) => clearTimeout(t)); + timersRef.current.clear(); + }; + }, []); + + return ( + + {children} + {typeof document !== "undefined" + ? createPortal( +
+ + {toasts.map((t) => ( + + ))} + +
, + document.body, + ) + : null} +
+ ); +} + +export function useToast(): ToastApi { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error("useToast must be used inside "); + } + return ctx; +} diff --git a/frontend/src/components/dashboard/WelcomeHero.tsx b/frontend/src/components/dashboard/WelcomeHero.tsx index 2b8d020..49ca027 100644 --- a/frontend/src/components/dashboard/WelcomeHero.tsx +++ b/frontend/src/components/dashboard/WelcomeHero.tsx @@ -115,7 +115,7 @@ export function WelcomeHero({
- {queryError ?
: null} + {queryError ? ( +
+ approvalsQuery.refetch()} + retryLabel={t("common.tryAgain")} + /> +
+ ) : null} {/* Contextual error banner for auto_publish_disabled */} {actionError?.errorCode === "auto_publish_disabled" ? ( @@ -74,7 +85,7 @@ export function ApprovalsPage() {
{approvalsQuery.isLoading ? ( - + ) : ( toast.success(t("toast.approved")), onError: (err) => { if (err instanceof ApiError) { setActionError({ message: err.message, errorCode: err.errorCode, }); + if (err.errorCode !== "auto_publish_disabled") { + toast.error(err.message); + } } else { setActionError({ message: String(err) }); + toast.error(String(err)); } }, }, @@ -106,10 +122,11 @@ export function ApprovalsPage() { rejectMutation.mutate( { id: currentApproval.id }, { + onSuccess: () => toast.info(t("toast.rejected")), onError: (err) => { - setActionError({ - message: err instanceof Error ? err.message : String(err), - }); + const message = err instanceof Error ? err.message : String(err); + setActionError({ message }); + toast.error(message); }, }, ); diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index a20a313..ecf3b03 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -6,7 +6,7 @@ import { useChatContext } from "../hooks/useChatContext"; import { useProjects } from "../hooks/useProjects"; import { ChatContainer } from "../components/chat/ChatContainer"; import { ChatSidebar } from "../components/chat/ChatSidebar"; -import { LoadingSpinner } from "../components/common/LoadingSpinner"; +import { ChatSkeleton } from "../components/common/Skeleton"; import { useI18n } from "../i18n"; function parseProjectId(value: string | null): number | null { @@ -45,7 +45,7 @@ export function ChatPage() { setSearchParams(nextParams, { replace: true }); }, [chat.projectId, chat.sessionReady, searchParams, setSearchParams]); - if (!chat.sessionReady) return ; + if (!chat.sessionReady) return ; return (
diff --git a/frontend/src/pages/CommunityPage.tsx b/frontend/src/pages/CommunityPage.tsx index b512335..d0edc6e 100644 --- a/frontend/src/pages/CommunityPage.tsx +++ b/frontend/src/pages/CommunityPage.tsx @@ -1,7 +1,7 @@ import { useParams } from "react-router"; import { useProjectSummary } from "../hooks/useProject"; import { useDiscussions, useCommunityChart } from "../hooks/useCommunityData"; -import { LoadingSpinner } from "../components/common/LoadingSpinner"; +import { ProjectSubpageSkeleton } from "../components/common/Skeleton"; import { ErrorAlert } from "../components/common/ErrorAlert"; import { EmptyState } from "../components/common/EmptyState"; import { ProjectHeader } from "../components/project/ProjectHeader"; @@ -86,7 +86,7 @@ export function CommunityPage() { const { data: chart } = useCommunityChart(projectId); const { t } = useI18n(); - if (isLoading) return ; + if (isLoading) return ; if (!summary) return ; const latestHits = chart?.scan_hits?.[chart.scan_hits.length - 1] ?? 0; @@ -205,7 +205,7 @@ export function CommunityPage() {
{/* KPI Cards */} -
+
; + if (loadingSummary) return ; if (!summary) return ; const geoScore = chart?.geo_score as (number | null)[] | undefined; @@ -104,7 +104,10 @@ export function GeoPage() { {t("geo.configHint")}
{loadingChart ? ( - +
+ + +
) : !chart?.labels?.length ? ( ) : ( @@ -162,7 +165,7 @@ export function GeoPage() { {/* KPI Cards */} -
+
scoreMut.mutate({ projectId })} disabled={scoreMut.isPending} - className="inline-flex shrink-0 items-center gap-2 rounded-lg border border-zinc-200 px-4 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 disabled:opacity-50" + className="inline-flex shrink-0 items-center gap-2 rounded-lg border border-zinc-200 px-4 py-2 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50" > {t("github.rescore")} @@ -290,7 +290,7 @@ export function GitHubLeadsPage() { diff --git a/frontend/src/pages/ProjectMonitorsPage.tsx b/frontend/src/pages/ProjectMonitorsPage.tsx index f3f6eb0..7caaadb 100644 --- a/frontend/src/pages/ProjectMonitorsPage.tsx +++ b/frontend/src/pages/ProjectMonitorsPage.tsx @@ -9,9 +9,10 @@ import { MonitorForm } from "../components/monitors/MonitorForm"; import { AnalysisDialog } from "../components/monitors/AnalysisDialog"; import { ProjectHeader } from "../components/project/ProjectHeader"; import { ProjectTabs } from "../components/project/ProjectTabs"; -import { LoadingSpinner } from "../components/common/LoadingSpinner"; +import { ProjectHeaderSkeleton, ListRowSkeleton, SkeletonFrame } from "../components/common/Skeleton"; import { ErrorAlert } from "../components/common/ErrorAlert"; import { EmptyState } from "../components/common/EmptyState"; +import { useToast } from "../components/common/Toast"; import { useI18n } from "../i18n"; export function ProjectMonitorsPage() { @@ -22,6 +23,7 @@ export function ProjectMonitorsPage() { const deleteMonitor = useDeleteMonitor(); const createMonitor = useCreateMonitor(); const { t } = useI18n(); + const toast = useToast(); const [selectedTaskId, setSelectedTaskId] = useState(null); const [selectedTaskUrl, setSelectedTaskUrl] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); @@ -39,7 +41,14 @@ export function ProjectMonitorsPage() { return () => window.clearTimeout(timeoutId); }, [dialogOpen, selectedTaskId, taskDone]); - if (projectLoading || monitorsLoading) return ; + if (projectLoading || monitorsLoading) { + return ( + + + + + ); + } if (error || !data) return ; const handleTaskCreated = (taskId: string, url: string) => { @@ -49,12 +58,19 @@ export function ProjectMonitorsPage() { }; const handleCreateMonitor = async (payload: { url: string; cron_expr: string }) => { - const result = await createMonitor.mutateAsync({ - ...payload, - locale: monitors[0]?.locale, - }); - if (result.task_id) { - handleTaskCreated(result.task_id, payload.url); + try { + const result = await createMonitor.mutateAsync({ + ...payload, + locale: monitors[0]?.locale, + }); + toast.success(t("toast.monitorAdded")); + if (result.task_id) { + toast.info(t("toast.scanStarted")); + handleTaskCreated(result.task_id, payload.url); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + throw err; } }; @@ -114,7 +130,13 @@ export function ProjectMonitorsPage() { /> deleteMonitor.mutate(id)} + onDelete={(id) => + deleteMonitor.mutate(id, { + onSuccess: () => toast.info(t("toast.monitorRemoved")), + onError: (err) => + toast.error(err instanceof Error ? err.message : String(err)), + }) + } onTaskCreated={handleTaskCreated} />
diff --git a/frontend/src/pages/SeoPage.tsx b/frontend/src/pages/SeoPage.tsx index 54cafce..1df52af 100644 --- a/frontend/src/pages/SeoPage.tsx +++ b/frontend/src/pages/SeoPage.tsx @@ -1,7 +1,7 @@ import { useParams } from "react-router"; import { useProjectSummary } from "../hooks/useProject"; import { useSeoChart } from "../hooks/useSeoData"; -import { LoadingSpinner } from "../components/common/LoadingSpinner"; +import { ProjectSubpageSkeleton, ChartCardSkeleton } from "../components/common/Skeleton"; import { ErrorAlert } from "../components/common/ErrorAlert"; import { EmptyState } from "../components/common/EmptyState"; import { ProjectHeader } from "../components/project/ProjectHeader"; @@ -34,7 +34,7 @@ export function SeoPage() { const { data: chart, isLoading: loadingChart } = useSeoChart(projectId); const { t } = useI18n(); - if (loadingSummary) return ; + if (loadingSummary) return ; if (!summary) return ; const perf = chart?.performance as (number | null)[] | undefined; @@ -63,13 +63,20 @@ export function SeoPage() { {loadingChart ? ( - +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
) : !chart?.labels?.length ? ( ) : (
{/* KPI Cards */} -
+
; + if (isLoading) return ; if (!summary) return ; const ranked = (serpLatest ?? []).filter((s) => s.position != null); @@ -114,7 +116,7 @@ export function SerpPage() {
{/* KPI Cards */} -
+
addKeyword.mutate(kw)} + onAdd={(kw) => + addKeyword.mutate(kw, { + onSuccess: () => toast.success(t("toast.keywordAdded")), + onError: (err) => toast.error(err instanceof Error ? err.message : String(err)), + }) + } isLoading={addKeyword.isPending} /> deleteKeyword.mutate(kwId)} + onDelete={(kwId) => + deleteKeyword.mutate(kwId, { + onSuccess: () => toast.info(t("toast.keywordRemoved")), + onError: (err) => toast.error(err instanceof Error ? err.message : String(err)), + }) + } /> From 107284a95b425a015aa5b8e0faf40ce58b232eab Mon Sep 17 00:00:00 2001 From: JingWen Fan <106414602+study8677@users.noreply.github.com> Date: Sat, 30 May 2026 23:19:00 +0800 Subject: [PATCH 2/2] feat(deploy): migrate production to Docker; deprecate systemd path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the legacy rsync + venv + systemctl restart deployment with a single Docker container. Cuts cold-deploy time on a fresh server from ~10 minutes (system Python + venv + pip + playwright install + browsers + crawl4ai-setup) to one `docker compose up -d --build`. Already cut over on newyork (https://www.aidcmo.com/app/ verified 200, DB migrated, scheduler picked up the 45 existing jobs). The systemd unit is stopped + disabled but left in place so rollback is one command. What was broken in the previous Dockerfile - Exposed 8080 — but nginx-on-host proxies to 8081 and 8080 is taken by sub2api on newyork, so the container would never receive traffic. - Installed `opencmo[all]` which excludes the `browser` extra, so playwright wasn't in the image. Crawl4ai's scan fallback paths would crash with `BrowserType.launch executable doesn't exist`. - Ran `playwright install-deps` (system libs only) but never `playwright install chromium` (the actual browser binary). - `crawl4ai-setup || true` masked legitimate post-install failures. - Ran as root; no signal handling; no healthcheck. What's new - Multi-stage build: node:20-alpine for the frontend bundle, then python:3.12-slim for the runtime with Chromium + crawl4ai pre-warmed. - Non-root user (uid 1000), tini as PID 1 for clean shutdowns, HEALTHCHECK against /api/v1/health. - Port configurable via OPENCMO_WEB_PORT (defaults to 8081 to match nginx upstream). - docker-compose.yml binds 127.0.0.1:8081 only, bind-mounts ./data for SQLite + report assets, caps log rotation at 20m × 5 files. - .dockerignore keeps the build context lean (excludes .venv, dist, node_modules, screenshots, .env, …). - deploy/docker-deploy.sh: one-shot rsync + build + up + health probe, with safe excludes so `--delete` never wipes the on-server .env or data directory. - docs/DOCKER.md: full migration story + rollback runbook. - CLAUDE.md: Docker is the primary path; systemd downgraded to emergency rollback. Also notes: don't smoke-test locally, the laptop isn't the deployment target — verify on newyork. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 86 +++++++++++++++++ CLAUDE.md | 50 ++++++---- Dockerfile | 98 +++++++++++++++++--- deploy/docker-deploy.sh | 97 +++++++++++++++++++ docker-compose.yml | 64 ++++++++++++- docs/DOCKER.md | 201 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 560 insertions(+), 36 deletions(-) create mode 100644 .dockerignore create mode 100755 deploy/docker-deploy.sh create mode 100644 docs/DOCKER.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8c1d25a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,86 @@ +# Keep the build context small and the image lean. +# +# Two things matter: +# 1. Anything COPYed (or implicitly included by a wildcard) gets uploaded +# to the docker daemon — make sure huge dev-only trees never go. +# 2. Anything that *isn't* COPYed still affects layer-cache invalidation +# via `COPY src/ ./src/` and friends. List paths even if no stage +# uses them so the daemon doesn't ship them. + +# Source-control + IDE +.git +.gitignore +.gitattributes +.github +.vscode +.idea +.cursor +.claude +.continue + +# Python build/runtime junk +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +.venv +venv +env +.python-version +.pytest_cache +.ruff_cache +.mypy_cache +.tox +htmlcov +.coverage +.coverage.* +*.cover + +# Node — the frontend stage installs its own deps from package-lock. +frontend/node_modules +frontend/dist +**/.npm +**/.yarn +**/yarn-error.log +**/.parcel-cache + +# Tests & local fixtures — runtime image doesn't need them. +tests/ +*.db +*.sqlite +*.sqlite3 + +# Captures / screenshots / video produced by capture_*.py scripts. +# These live at the repo root and are megabytes of binary noise. +capture_*.py +assets/ +video/ +docs/ +*.png +*.jpg +*.jpeg +*.gif +*.mp4 + +# Local secrets — env_file mounts .env into the container at runtime; +# never bake it into the image. +.env +.env.* +!.env.example + +# OS + editor cruft +.DS_Store +Thumbs.db +*.swp +*~ + +# Docker itself +Dockerfile +docker-compose.yml +.dockerignore + +# Big planning docs that don't ship to prod. +*.md +!README.md +!frontend/package*.json diff --git a/CLAUDE.md b/CLAUDE.md index 8dca079..b3fcb0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,41 +127,59 @@ When optimizing report generation or other LLM-heavy workflows: - **Nginx security headers**: `Strict-Transport-Security` + `X-Frame-Options: DENY` configured in `aidcmo.conf`. - **Port allocation**: Do not assume production app port is `8080`. `8080` is occupied by `sub2api` on `newyork`; OpenCMO uses `8081`. - **BWG role**: `BWG` is no longer the primary OpenCMO host. Treat it as a lightweight box, temporary reverse proxy, or fallback node unless explicitly re-promoted. -- **Browser-backed scans**: SEO/context fallback paths use `crawl4ai`/Playwright. Fresh servers need browser binaries installed, or scans will fail with `BrowserType.launch` executable errors. +- **Browser-backed scans**: SEO/context fallback paths use `crawl4ai`/Playwright. The Docker image installs the Chromium binary during build (`playwright install --with-deps chromium`), so containers work out of the box. For the legacy systemd path, fresh servers need `playwright install chromium` manually or scans fail with `BrowserType.launch` executable errors. +- **Deployment**: Production runs in Docker. The host runs `docker compose up -d` from `/opt/OpenCMO`; the legacy systemd unit (`opencmo.service`) is disabled but left in place for rollback. See `docs/DOCKER.md` for the full migration story. +- **Local testing**: Don't `npm run dev` / `opencmo-web` locally for verification. The user's laptop is not the deployment target and the local environment doesn't mirror production. Verify on `newyork` via SSH + curl. Local `npm run build` and `pytest` are fine (no runtime dependency on prod topology). ## Deployment (newyork — aidcmo.com) -### Deploy frontend assets to New York +### Default path: Docker ```bash -cd frontend && npm run build # Build locally (avoid server-side frontend builds) -rsync -avz --delete frontend/dist/ root@192.3.16.77:/opt/OpenCMO/frontend/dist/ +./deploy/docker-deploy.sh # rsync + docker compose build + up -d + health probe +./deploy/docker-deploy.sh --no-build # config-only change (skip image rebuild) +./deploy/docker-deploy.sh --logs # tail container logs after deploy ``` -### Deploy backend code to New York +Manual equivalent: ```bash +# Sync source (excludes data/, .env, frontend/dist — those live on the server) rsync -avz --delete \ - --exclude '.git' \ - --exclude 'frontend/node_modules' \ - --exclude 'frontend/dist' \ - --exclude '.venv' \ + --exclude '.git' --exclude '.venv' \ + --exclude 'frontend/node_modules' --exclude 'frontend/dist' \ + --exclude 'data/' --exclude '.env' --exclude '.env.*' \ ./ root@192.3.16.77:/opt/OpenCMO/ -ssh newyork "cd /opt/OpenCMO && source .venv/bin/activate && pip install -e . -q && systemctl restart opencmo" + +ssh newyork "cd /opt/OpenCMO && docker compose up -d --build" +``` + +### Health + log checks + +```bash +ssh newyork "cd /opt/OpenCMO && docker compose ps" # container + (healthy) status +ssh newyork "cd /opt/OpenCMO && docker compose logs -f --tail=100 opencmo" +ssh newyork "ss -ltnp | grep -E ':80|:443|:8081'" # nginx + container ports +curl -sL -o /dev/null -w '%{http_code}\n' https://www.aidcmo.com/app/ ``` -### New York service / runtime checks +### Rollback to systemd (emergency only) ```bash -ssh newyork "systemctl status opencmo --no-pager" -ssh newyork "journalctl -u opencmo -n 200 --no-pager" -ssh newyork "ss -ltnp | grep -E ':80|:443|:8081'" +ssh newyork "cd /opt/OpenCMO && docker compose down && systemctl enable --now opencmo" +# Nginx upstream is unchanged (127.0.0.1:8081) so traffic resumes instantly. +# DB: container reads /opt/OpenCMO/data/data.db; systemd reads ~/.opencmo/data.db. +# If the schema migrated forward under Docker, `cp /opt/OpenCMO/data/data.db ~/.opencmo/data.db` before starting systemd. ``` -### Install Playwright browsers (when scan workers need them) +### Legacy frontend-only deploy (still valid for tiny UI tweaks) + +The Docker image bakes the frontend in at build time. To ship UI changes without a full image rebuild you can still rsync the bundle into the container's build context and rebuild: ```bash -ssh newyork "cd /opt/OpenCMO && .venv/bin/playwright install chromium" +cd frontend && npm run build +rsync -avz --delete frontend/dist/ root@192.3.16.77:/opt/OpenCMO/frontend/dist/ +ssh newyork "cd /opt/OpenCMO && docker compose up -d --build" ``` ### BWG (optional fallback / proxy only) diff --git a/Dockerfile b/Dockerfile index 4d0aa2e..61aaee8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,94 @@ -# Stage 1: Build frontend -FROM node:20-slim AS frontend-builder +# syntax=docker/dockerfile:1.7 + +# ──────────────────────────────────────────────────────────────────────────── +# Stage 1 — build the frontend bundle (Node 20, alpine for size) +# ──────────────────────────────────────────────────────────────────────────── +FROM node:20-alpine AS frontend-builder WORKDIR /app/frontend + +# Cache dependency layer separately from source. Use `npm ci` (lockfile-aware, +# reproducible) when the lockfile exists; fall back to `npm install` if it +# doesn't so the repo stays buildable without a commit-locked lockfile. COPY frontend/package*.json ./ -RUN npm ci --silent +RUN if [ -f package-lock.json ]; then npm ci --silent; else npm install --silent; fi + COPY frontend/ ./ RUN npm run build -# Stage 2: Python runtime -FROM python:3.11-slim -WORKDIR /app +# ──────────────────────────────────────────────────────────────────────────── +# Stage 2 — Python runtime + Playwright Chromium baked in +# ──────────────────────────────────────────────────────────────────────────── +# Use a slim base. Playwright's Python package downloads its own browser +# binaries; we install OS-level deps separately and then download Chromium. +FROM python:3.12-slim AS runtime + +# System packages: certificates for outbound TLS, curl for HEALTHCHECK, +# tini for proper PID-1 signal forwarding, and the libraries Chromium +# needs at runtime (added by `playwright install-deps`). +ENV DEBIAN_FRONTEND=noninteractive \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 RUN apt-get update && apt-get install -y --no-install-recommends \ - curl ca-certificates \ + ca-certificates \ + curl \ + tini \ && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python deps in a cache-friendly order: project metadata first +# (so requirement changes don't bust the source-copy layer), then source. COPY pyproject.toml README.md ./ COPY src/ ./src/ -RUN pip install --no-cache-dir -e ".[all]" -# Install system deps for Chromium (used by crawl4ai), then set up crawl4ai -RUN playwright install-deps chromium \ - && crawl4ai-setup || true + +# Install the app with EVERY runtime extra plus the `browser` extra so that +# playwright is present for crawl4ai's scan fallback paths. The previous +# Dockerfile installed `[all]` which excludes `browser` — scans then crashed +# with "BrowserType.launch executable doesn't exist". +RUN pip install --no-cache-dir -e ".[all,browser]" + +# Install Chromium + its OS-level deps in one go. `--with-deps` runs apt +# under the hood to add the missing shared libs (libnss3, libatk-1.0-0, …). +# This pulls ~280 MB but means every scan path works on a fresh container. +RUN playwright install --with-deps chromium \ + && rm -rf /var/lib/apt/lists/* + +# Run crawl4ai's post-install (downloads its own models/templates). It +# previously had `|| true` which masked real failures — let it fail loudly. +RUN crawl4ai-setup + +# Pull the pre-built frontend bundle from stage 1 into the location the +# FastAPI app serves static files from. COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Non-root user. Playwright stores its browser cache under HOME so we point +# it at the user's home and give the user ownership of /app and /data. +RUN useradd --create-home --shell /bin/bash --uid 1000 opencmo \ + && mkdir -p /data \ + && chown -R opencmo:opencmo /app /data \ + && cp -r /root/.cache/ms-playwright /home/opencmo/.cache/ms-playwright 2>/dev/null || true \ + && chown -R opencmo:opencmo /home/opencmo/.cache 2>/dev/null || true +USER opencmo + +# Persistent state lives under /data. The default DB path matches. VOLUME ["/data"] -ENV OPENCMO_DB_PATH=/data/data.db -ENV OPENCMO_WEB_HOST=0.0.0.0 -EXPOSE 8080 -CMD ["opencmo-web"] +ENV OPENCMO_DB_PATH=/data/data.db \ + OPENCMO_WEB_HOST=0.0.0.0 \ + OPENCMO_WEB_PORT=8081 \ + PLAYWRIGHT_BROWSERS_PATH=/home/opencmo/.cache/ms-playwright + +EXPOSE 8081 + +# Healthcheck — same endpoint nginx-on-host probes when it hands off traffic. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${OPENCMO_WEB_PORT}/api/v1/health" || exit 1 + +# tini handles SIGTERM properly so `docker compose down` is fast and clean. +ENTRYPOINT ["/usr/bin/tini", "--"] + +# Run uvicorn directly so the port is configurable via env. The `opencmo-web` +# console script hard-codes port=8080 in its signature, which is why we +# bypass it here. +CMD ["sh", "-c", "uvicorn opencmo.web.app:app --host ${OPENCMO_WEB_HOST} --port ${OPENCMO_WEB_PORT}"] diff --git a/deploy/docker-deploy.sh b/deploy/docker-deploy.sh new file mode 100755 index 0000000..ff38543 --- /dev/null +++ b/deploy/docker-deploy.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# deploy/docker-deploy.sh — push the current working tree to newyork and +# (re)build the Docker container in place. +# +# Usage: +# ./deploy/docker-deploy.sh # build + restart +# ./deploy/docker-deploy.sh --no-build # restart only (config-only change) +# ./deploy/docker-deploy.sh --logs # tail logs after deploy +# +# Pre-requisites on the server (one-time): +# - Docker + Docker Compose plugin installed +# - /opt/OpenCMO/.env populated with provider keys (copy from the legacy +# systemd setup if migrating) +# - /opt/OpenCMO/data/ directory exists (or symlink to a backup location) +# +# Pre-requisites locally: +# - `ssh newyork ...` works (key auth, the alias resolves to root@192.3.16.77) + +set -euo pipefail + +HOST="${OPENCMO_DEPLOY_HOST:-newyork}" +REMOTE_DIR="${OPENCMO_DEPLOY_DIR:-/opt/OpenCMO}" +DO_BUILD=1 +TAIL_LOGS=0 + +for arg in "$@"; do + case "$arg" in + --no-build) DO_BUILD=0 ;; + --logs) TAIL_LOGS=1 ;; + -h|--help) + sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "Unknown flag: $arg" >&2; exit 2 ;; + esac +done + +cd "$(dirname "$0")/.." +ROOT="$(pwd)" +echo "→ syncing $ROOT → $HOST:$REMOTE_DIR" + +# Exclude everything the container builds for itself, plus host-only state +# (venv, node_modules, the host's ./data dir which we never want to overwrite), +# plus the on-server secrets (`.env`) which are gitignored locally and would +# otherwise be wiped by `--delete`. +rsync -avz --delete \ + --exclude '.git/' \ + --exclude '.venv/' \ + --exclude 'frontend/node_modules/' \ + --exclude 'frontend/dist/' \ + --exclude 'data/' \ + --exclude '.env' \ + --exclude '.env.*' \ + --exclude '__pycache__/' \ + --exclude '*.pyc' \ + --exclude '.pytest_cache/' \ + --exclude '.ruff_cache/' \ + --exclude '.mypy_cache/' \ + --exclude '.DS_Store' \ + --exclude '.claude/' \ + ./ "$HOST:$REMOTE_DIR/" + +REMOTE_CMD="" +if [ "$DO_BUILD" -eq 1 ]; then + echo "→ building image on $HOST" + REMOTE_CMD="cd $REMOTE_DIR && docker compose build && docker compose up -d" +else + echo "→ restarting container on $HOST (no rebuild)" + REMOTE_CMD="cd $REMOTE_DIR && docker compose up -d" +fi + +# `docker compose up -d` is idempotent: it recreates the container only if +# config/image changed. So a `--no-build` invocation after a config-only +# change is the fastest path back to a clean container. +ssh "$HOST" "$REMOTE_CMD" + +echo "→ verifying health" +# Give uvicorn a beat to bind + serve. Retry rather than sleep-and-pray. +for i in 1 2 3 4 5 6 7 8 9 10; do + if ssh "$HOST" "curl -fsS http://127.0.0.1:8081/api/v1/health >/dev/null"; then + echo "✓ healthy after ${i} attempt(s)" + break + fi + if [ "$i" -eq 10 ]; then + echo "✗ never became healthy — dumping last 60 log lines:" >&2 + ssh "$HOST" "cd $REMOTE_DIR && docker compose logs --tail=60 opencmo" >&2 || true + exit 1 + fi + sleep 2 +done + +echo "→ done. Public probe:" +curl -sS -o /dev/null -w " https://www.aidcmo.com/app/ → HTTP %{http_code}\n" -L https://www.aidcmo.com/app/ + +if [ "$TAIL_LOGS" -eq 1 ]; then + ssh "$HOST" "cd $REMOTE_DIR && docker compose logs -f --tail=50 opencmo" +fi diff --git a/docker-compose.yml b/docker-compose.yml index e131d34..8e9f7e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,67 @@ +# docker-compose.yml — production deployment for OpenCMO. +# +# Designed to slot in behind the existing nginx-on-host TLS terminator: +# nginx listens on 80/443 (with the aidcmo.com cert) and proxies to +# 127.0.0.1:8081, which is what this container exposes. +# +# Quick reference: +# docker compose up -d --build # build + (re)start +# docker compose logs -f opencmo # tail logs +# docker compose down # stop (data preserved in ./data) +# docker compose pull && docker compose up -d # if using a registry image +# +# Migration from the legacy systemd unit: +# 1. `systemctl stop opencmo && systemctl disable opencmo` +# 2. Copy SQLite over: `mkdir -p ./data && cp ~/.opencmo/data.db ./data/` +# (or bind-mount ~/.opencmo at /data — see the alt mount below) +# 3. `docker compose up -d --build` +# 4. Rollback: `docker compose down && systemctl enable --now opencmo` + services: opencmo: - build: . + build: + context: . + dockerfile: Dockerfile + image: opencmo:latest + container_name: opencmo + + # Bind to localhost only — nginx-on-host proxies in. Never expose + # the container port directly to the internet. ports: - - "127.0.0.1:8080:8080" + - "127.0.0.1:8081:8081" + + # Persistent state. Bind mount (not named volume) so the user can + # `cp ./data/data.db backup.db` from the host without docker tooling. + # If migrating from systemd, swap this for: ${HOME}/.opencmo:/data volumes: - - opencmo_data:/data + - ./data:/data + + # All secrets and provider config flow through .env on the host. env_file: - .env + + # Belt and suspenders: respect any port override from .env, but + # the Dockerfile default of 8081 already matches nginx. + environment: + OPENCMO_WEB_HOST: "0.0.0.0" + OPENCMO_WEB_PORT: "8081" + restart: unless-stopped -volumes: - opencmo_data: + # Surface the container's own healthcheck to docker so `docker ps` + # shows (healthy)/(unhealthy) and `restart: unless-stopped` kicks in + # if uvicorn wedges. + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8081/api/v1/health || exit 1"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 + + # Cap log volume — uvicorn is chatty under load and the default + # json-file driver has no rotation, which has bitten servers before. + logging: + driver: "json-file" + options: + max-size: "20m" + max-file: "5" diff --git a/docs/DOCKER.md b/docs/DOCKER.md new file mode 100644 index 0000000..c11c65e --- /dev/null +++ b/docs/DOCKER.md @@ -0,0 +1,201 @@ +# Docker deployment + +OpenCMO ships as a single Docker container designed to slot in behind an +existing nginx-on-host TLS terminator. This is the recommended deployment +path; the legacy `rsync + systemd + venv` flow is kept around only as a +rollback target. + +## What's in the image + +- Python 3.12-slim base +- All optional extras installed (`opencmo[all,browser]`) — including + `playwright` so crawl4ai's scan fallback paths work without a separate + `playwright install` on the host +- Chromium browser baked in via `playwright install --with-deps chromium` +- Frontend bundle built in a stage-1 Node container and copied into + `/app/frontend/dist` +- Non-root user (`opencmo`, uid 1000) +- `tini` as PID 1 for clean signal handling + +## What lives outside the image + +| Thing | Where on host | Where in container | +|---|---|---| +| SQLite DB + state | `/opt/OpenCMO/data/` | `/data/` | +| Secrets / API keys | `/opt/OpenCMO/.env` | env vars | +| TLS certs + nginx | host filesystem | n/a | + +## Quick start (fresh server) + +```bash +# 1. Prerequisites +apt-get install -y docker.io docker-compose-plugin +git clone https://github.com/study8677/OpenCMO /opt/OpenCMO +cd /opt/OpenCMO + +# 2. Populate secrets +cp .env.example .env +$EDITOR .env # set OPENAI_API_KEY etc. + +# 3. Pre-create the data dir so the bind mount is owned correctly +mkdir -p data +chown 1000:1000 data + +# 4. Build + start +docker compose up -d --build + +# 5. Verify +curl -fsS http://127.0.0.1:8081/api/v1/health +docker compose ps # should show (healthy) +``` + +Then point nginx at `127.0.0.1:8081` (see `/etc/nginx/sites-enabled/aidcmo.conf` +on the production box for the existing pattern — it's unchanged from the +systemd era). + +## Day-to-day operations + +```bash +# Tail logs +docker compose logs -f opencmo + +# Restart after a config change in .env +docker compose up -d + +# Pull new source + rebuild + restart (typical deploy) +git pull && docker compose up -d --build + +# Stop everything (data preserved in ./data) +docker compose down + +# Inspect health status +docker compose ps +docker inspect --format='{{.State.Health.Status}}' opencmo +``` + +## Deploying from a local checkout + +`deploy/docker-deploy.sh` rsyncs the current working tree to newyork and +runs the build+restart there: + +```bash +./deploy/docker-deploy.sh # build + restart + health probe +./deploy/docker-deploy.sh --no-build # restart only (config-only changes) +./deploy/docker-deploy.sh --logs # also tail logs after deploy +``` + +It excludes the host-owned `data/`, `.venv/`, `frontend/node_modules/`, +and `frontend/dist/` so the server's persistent state and build caches +are never clobbered. + +## Migrating from systemd + +If the box was previously running OpenCMO under systemd (the legacy +`/etc/systemd/system/opencmo.service` unit that invoked +`/opt/OpenCMO/.venv/bin/uvicorn opencmo.web.app:app --host 0.0.0.0 --port 8081`), +follow this sequence: + +```bash +# On newyork, as root: +cd /opt/OpenCMO + +# 1. Snapshot the DB before touching anything. +mkdir -p data +cp -a ~/.opencmo/data.db data/data.db +chown -R 1000:1000 data + +# 2. Stop the old service. Disable it so it won't fight for port 8081 +# on the next reboot — but leave the unit file in place for rollback. +systemctl stop opencmo +systemctl disable opencmo + +# 3. Start the container. First build takes ~3 minutes because of the +# Chromium download; subsequent builds reuse cached layers. +docker compose up -d --build + +# 4. Verify nginx can reach the new backend. +curl -fsS http://127.0.0.1:8081/api/v1/health +curl -fsSL -o /dev/null -w "%{http_code}\n" https://www.aidcmo.com/app/ + +# 5. Tail for the first few minutes to catch anything weird. +docker compose logs -f opencmo +``` + +### Rollback + +```bash +docker compose down +systemctl enable --now opencmo +# nginx config didn't change — traffic flows again instantly. +``` + +If you also need to revert the DB (because the container schema migrated +it forward), restore the snapshot you took in step 1: + +```bash +cp -a data/data.db ~/.opencmo/data.db +chown root:root ~/.opencmo/data.db +``` + +## Tuning + +### Different host port + +Edit `docker-compose.yml`: + +```yaml +ports: + - "127.0.0.1:8082:8081" # left side = host, right side = container +``` + +…and update the nginx upstream to match. + +### Persistent data elsewhere + +Bind a different host path: + +```yaml +volumes: + - /var/lib/opencmo:/data +``` + +### CPU / memory limits + +Add a `deploy.resources` block. Useful if the box runs other services +(it does — `sub2api` is on 8080 already): + +```yaml +deploy: + resources: + limits: + cpus: "2.0" + memory: 2g +``` + +### Custom model providers (NVIDIA, DeepSeek, Ollama) + +These flow through `.env` — see `.env.example` for the env-var names. +No image change needed. + +## Common issues + +**`Cannot connect to the Docker daemon`** — `systemctl start docker` then +`usermod -aG docker $USER` and re-login if you don't want to `sudo`. + +**Container restart loop** — usually a config typo. Tail logs: +`docker compose logs --tail=200 opencmo`. The most common cause is a +missing `OPENAI_API_KEY` (or equivalent) in `.env`. + +**Healthcheck never goes green** — uvicorn started but DB init failed. +Check `data/` permissions (`chown -R 1000:1000 data`) and look for +`PermissionError` in the logs. + +**Browser-backed scans fail with `BrowserType.launch executable doesn't +exist`** — only happens if someone rebuilt the image with `--no-cache` +and the `playwright install` step failed silently. Inspect the build +log and rerun. The Dockerfile no longer suppresses errors from this step. + +**Old `~/.opencmo/data.db` not picked up** — verify the bind mount path +in `docker-compose.yml` matches where you copied the DB to. The default +is `./data/data.db` on the host (relative to where `docker compose` is +run, i.e. `/opt/OpenCMO/data/data.db`).