Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 2 additions & 84 deletions apps/admin/src/components/organisms/overview-breakdown-charts.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,7 @@
"use client";

import {
Bar,
BarChart,
CartesianGrid,
Cell,
Scatter,
ScatterChart,
Tooltip,
XAxis,
YAxis,
ZAxis,
} from "recharts";
import { ChartContainer, ChartTooltip } from "@/components/atoms/chart";
import { CartesianGrid, Cell, Scatter, ScatterChart, Tooltip, XAxis, YAxis, ZAxis } from "recharts";
import { ChartContainer } from "@/components/atoms/chart";

export type DistributionRow = { label: string; value: number; color: string; detail?: string };
export function DistributionChart({
Expand Down Expand Up @@ -186,74 +175,3 @@ export function CoverageChart({
</div>
);
}

export function JobOutcomesChart({
rows,
}: {
rows: { name: string; completed: number; failed: number }[];
}) {
const active = rows.filter((row) => row.completed + row.failed > 0);
if (!active.length)
return (
<p className="py-16 text-center text-sm text-muted-foreground">
No completed or failed jobs in this period.
</p>
);
return (
<>
<div className="mb-3 flex gap-5 text-xs text-muted-foreground">
<span>
<span className="mr-2 inline-block size-2 rounded-sm bg-chart-2" />
Completed
</span>
<span>
<span className="mr-2 inline-block size-2 rounded-sm bg-destructive" />
Failed
</span>
</div>
<ChartContainer label="Job outcome rates" className="h-80">
<BarChart
data={active}
layout="vertical"
stackOffset="expand"
accessibilityLayer
margin={{ right: 15, top: 5, bottom: 5 }}
>
<CartesianGrid horizontal={false} stroke="var(--border)" strokeDasharray="3 4" />
<XAxis
type="number"
domain={[0, 1]}
tickFormatter={(value) => `${Math.round(value * 100)}%`}
tickLine={false}
axisLine={false}
/>
<YAxis
type="category"
dataKey="name"
width={120}
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
/>
<Tooltip content={(props) => <ChartTooltip {...props} />} />
<Bar
dataKey="completed"
name="Completed"
stackId="outcomes"
fill="var(--chart-2)"
maxBarSize={24}
isAnimationActive={false}
/>
<Bar
dataKey="failed"
name="Failed"
stackId="outcomes"
fill="var(--destructive)"
maxBarSize={24}
isAnimationActive={false}
/>
</BarChart>
</ChartContainer>
</>
);
}
13 changes: 0 additions & 13 deletions apps/admin/src/components/organisms/overview-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,19 +539,6 @@ export function OverviewProcessing({ data }: { data: AdminOverview }) {
}

export type OverviewSection = "sources" | "personalization" | "operations";
export function OverviewDetails({ data }: { data: AdminOverview }) {
return (
<div className="min-w-0 space-y-6">
<div className="grid items-start gap-6 xl:grid-cols-2">
<OverviewAudience data={data} section="readers" />
<OverviewAudience data={data} section="topics" />
</div>
<OverviewAudience data={data} section="personalization" />
<OverviewSources data={data} />
<OverviewProcessing data={data} />
</div>
);
}

export function OverviewJobReliability({ data }: { data: AdminOverview }) {
const rows = (data.insights?.processing ?? [])
Expand Down
3 changes: 0 additions & 3 deletions apps/admin/src/lib/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,6 @@ export const resources: Record<Resource, ResourceSpec> = {
},
};
export const resourceKeys = Object.keys(resources) as Resource[];
export function isResource(value: string): value is Resource {
return Object.hasOwn(resources, value);
}
export function humanize(value: string) {
return value.replaceAll("_", " ").replace(/^./, (char) => char.toUpperCase());
}
Expand Down
22 changes: 1 addition & 21 deletions apps/admin/src/lib/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,7 @@ import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type {
AdminIdentity,
AdminOverview,
AuthConfig,
UserSettings,
} from "@/lib/api/generated/models";
import type { AdminIdentity, AuthConfig, UserSettings } from "@/lib/api/generated/models";
import { adminApiOrigin } from "./config";

export async function currentAdmin(): Promise<AdminIdentity | null> {
Expand Down Expand Up @@ -43,21 +38,6 @@ export async function authConfiguration(): Promise<AuthConfig> {
return response.json() as Promise<AuthConfig>;
}

export async function initialOverview(days = 30): Promise<AdminOverview> {
const jar = await cookies();
const session = jar.get("__Host-devfeed_admin_session") ?? jar.get("devfeed_admin_session");
if (!session) redirect("/login");
const response = await fetch(`${adminApiOrigin()}/v1/admin/overview?days=${days}`, {
headers: { Cookie: `${session.name}=${session.value}`, ...traceHeaders() },
cache: "no-store",
redirect: "error",
signal: AbortSignal.timeout(10_000),
});
if (response.status === 401 || response.status === 403) redirect("/login");
if (!response.ok) throw new Error("Admin service unavailable");
return response.json() as Promise<AdminOverview>;
}

export const initialUserSettings = cache(async (): Promise<UserSettings> => {
const jar = await cookies();
const session = jar.get("__Host-devfeed_admin_session") ?? jar.get("devfeed_admin_session");
Expand Down
5 changes: 3 additions & 2 deletions apps/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ cache of public article/topic records survives same-tab reloads; it contains no
sessions, CSRF tokens, recommendation reasons, or personal engagement state.
Snapshots expire after 24 hours. Storage restrictions can prevent reload recovery.

The new `/api/v1/feed/options` route provides contextual filter choices. Older
deployments fall back to the source catalog and standard content/language choices.
The `/api/v1/feed/options` route provides contextual filter choices and is required
for extension feed filters. If options are unavailable, successfully loaded articles
remain readable, and refreshing the tab restores filters once the route recovers.
Topics and sources use the existing paginated public catalog endpoints.

## Reader parity
Expand Down
17 changes: 2 additions & 15 deletions apps/extensions/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,7 @@ import {
import { SearchFilters } from "../../web/src/components/search-filters";
import { LoadingSkeleton } from "../../web/src/components/loading-skeleton";
import { configureReaderRuntime, readerRequest } from "../../web/src/lib/reader-runtime";
import {
contentTypes,
feedParams,
latestFeedParams,
parseFilters,
} from "../../web/src/lib/feed-query";
import { feedParams, latestFeedParams, parseFilters } from "../../web/src/lib/feed-query";
import { parseSearchOptions, normalizeSearch, type SearchResponse } from "../../web/src/lib/search";
import type { FeedPage, FeedOptions, Topic, Source } from "../../web/src/lib/types";
import { createReaderTransport, publicOrigin } from "./transport";
Expand Down Expand Up @@ -156,15 +151,7 @@ function Reader({
const params = feedParams(resolvedFilters);
const [feed, options] = await Promise.allSettled([
read<FeedPage>(`/api/v1/feed?${latestFeedParams(resolvedFilters)}`, signal),
read<FeedOptions>(`/api/v1/feed/options?${params}`, signal).catch(async () => {
// Compatibility with deployments predating the public options route.
const { items: sources } = await read<{ items: Source[] }>("/api/v1/sources", signal);
return {
sources,
content_types: [...contentTypes],
languages: ["en", "es", "fr", "de", "pt", "ja", "zh", "ko", "hi"],
};
}),
read<FeedOptions>(`/api/v1/feed/options?${params}`, signal),
]);
if (!signal.aborted)
setState({
Expand Down
26 changes: 26 additions & 0 deletions apps/extensions/tests/reader.browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ test(
let engagementPagination = false;
let failNextPage = true;
let failArticle = true;
let feedOptionsStatus = 200;
let articleFeedGate;
await context.route("https://identity.example/authorize?**", (route) =>
route.fulfill({ contentType: "text/html", body: "<p>Sign-in provider</p>" }),
Expand Down Expand Up @@ -191,6 +192,8 @@ test(
} else if (url.pathname === "/api/v1/user/auth/me") {
json = null;
} else if (url.pathname === "/api/v1/feed/options") {
if (feedOptionsStatus !== 200)
return route.fulfill({ status: feedOptionsStatus, json: {} });
json = {
sources: article.sources,
content_types: ["article", "news", "tutorial", "release", "comparison", "opinion"],
Expand Down Expand Up @@ -261,6 +264,29 @@ test(
await checkArticleGrid(page, path.join(extension, "../grid-" + browser));
assert.ok(page.url().startsWith("chrome-extension://"));
await page.waitForURL(/#\/latest$/);
for (const status of [404, 503]) {
feedOptionsStatus = status;
const sourceRequests = requests.filter((url) => url.pathname === "/api/v1/sources").length;
const optionsResponse = page.waitForResponse(
(response) => new URL(response.url()).pathname === "/api/v1/feed/options",
);
await page.reload();
assert.equal((await optionsResponse).status(), status);
await page.locator(".article-card").first().waitFor();
assert.deepEqual(await page.locator(".feed-toolbar a").allTextContents(), ["All"]);
assert.equal(
requests.filter((url) => url.pathname === "/api/v1/sources").length,
sourceRequests,
"Unavailable feed options must not trigger a broad source-catalog fallback",
);
}
feedOptionsStatus = 200;
await page.reload();
await page
.locator(".feed-toolbar")
.getByRole("link", { name: "News", exact: true })
.waitFor();
await page.locator(".article-card").first().waitFor();
const publicProfile = await context.newPage();
await publicProfile.goto(page.url().split("#")[0] + "#/users/reader");
await publicProfile.getByRole("heading", { name: "Reader Profile" }).waitFor();
Expand Down
3 changes: 0 additions & 3 deletions apps/web/src/lib/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
export type McpClient = "vscode" | "codex" | "claude" | "cursor" | "other";

export const mcpServerGuide =
"https://github.com/abhi1693/devfeed.tech/blob/master/apps/mcp/README.md";

export const mcpClients: Record<
McpClient,
{
Expand Down
9 changes: 1 addition & 8 deletions packages/core/src/job_retries.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Explicit retries use current eligibility and the pipelines' coalescing requests."""

from sqlalchemy import and_, case, or_, select, tuple_
from sqlalchemy import and_, or_, select, tuple_
from sqlalchemy.orm import aliased

from devfeed_core.analysis import request_analysis
Expand Down Expand Up @@ -58,13 +58,6 @@ def retry_candidate(model):
return and_(failed, ~superseded)


def job_display_status(model):
return case(
(and_(model.status == "failed", ~retry_candidate(model)), "retried"),
else_=model.status,
)


def retry_notification(session, identifier):
job = session.scalar(
select(NotificationDelivery)
Expand Down
7 changes: 0 additions & 7 deletions packages/core/src/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,6 @@ class SourcePublicOut(SourceRef):
created_at: datetime


class SourceSubmissionOut(SourceRef):
feed_url: str
approval_status: ApprovalStatus
submitted_by: SourceSubmitterOut | None
created_at: datetime


class ImageJobOut(ORMModel):
id: uuid.UUID
article_id: uuid.UUID | None
Expand Down
82 changes: 2 additions & 80 deletions packages/core/src/topic_corrections.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,94 +2,16 @@

import uuid

from sqlalchemy import or_, select
from sqlalchemy import select

from devfeed_core.analysis import snapshot_hash
from devfeed_core.models import ArticleTopic, Tag, Topic, TopicProposal, TopicRelation, utcnow
from devfeed_core.models import Topic, TopicProposal
from devfeed_core.services import OperationConflict, RecordNotFound
from devfeed_core.topic_auto_approval import ACTOR
from devfeed_core.topic_proposals import TopicReview, review_proposal, snapshot
from devfeed_core.topics import TopicWrite, lock_topics


def retract_automatic_topic(session, identifier, *, expected_hash, note, actor):
"""Retire an unchanged automatic approval after its links have been reconciled.

Retain the rejected identity so imports cannot silently recreate it, and retain
the complete previous review in the proposal's correction evidence.
"""
if not note.strip() or len(note) > 1000:
raise ValueError("A bounded correction reason is required")
lock_topics(session)
topic = session.scalar(
select(Topic)
.where(Topic.id == identifier)
.with_for_update(nowait=True)
.execution_options(populate_existing=True)
)
if topic is None:
raise RecordNotFound("Topic not found")
before = snapshot(topic)
if topic.status != "active" or snapshot_hash(before) != expected_hash:
raise OperationConflict("Topic changed before retraction")
previous = session.scalar(
select(TopicProposal)
.where(TopicProposal.topic_id == identifier, TopicProposal.status == "approved")
.order_by(TopicProposal.reviewed_at.desc(), TopicProposal.id.desc())
.limit(1)
.with_for_update(nowait=True)
)
if (
previous is None
or previous.reviewed_at is None
or previous.reviewed_by != ACTOR
or previous.applied != before
):
raise OperationConflict("Only an unchanged automatic approval can be retracted")
if (
session.scalar(select(ArticleTopic.article_id).where(ArticleTopic.topic_id == identifier))
or session.scalar(select(Tag.id).where(Tag.topic_id == identifier))
or session.scalar(
select(TopicRelation.topic_id).where(
or_(
TopicRelation.topic_id == identifier,
TopicRelation.related_topic_id == identifier,
)
)
)
or session.scalar(
select(TopicProposal.id).where(
TopicProposal.topic_id == identifier, TopicProposal.status == "pending"
)
)
):
raise OperationConflict(
"Reconcile article, tag, relationship and pending proposal links first"
)
now = utcnow()
previous.evidence = [
*previous.evidence,
{
"provider": "authorized_topic_retraction",
"actor": actor,
"reason": note,
"corrected_at": now.isoformat(),
"previous_status": previous.status,
"previous_reviewed_at": previous.reviewed_at.isoformat(),
"previous_reviewed_by": previous.reviewed_by,
"previous_review_note": previous.review_note,
"previous_snapshot": before,
},
]
previous.status, previous.reviewed_at, previous.reviewed_by = "rejected", now, actor
previous.review_note = note
topic.status = "rejected"
from devfeed_core.tag_topic_discovery import catalog_changed

catalog_changed(session)
session.flush()


def correct_automatic_topic(session, identifier, *, expected_hash, body: TopicWrite, note, actor):
"""Caller supplies an explicitly reviewed replacement and expected current snapshot."""
if not note.strip() or len(note) > 1000:
Expand Down
Loading
Loading