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
24 changes: 24 additions & 0 deletions admin/src/components/FeedbackBuildScope.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { SegmentedControl, SegmentedControlItem, SegmentedControlLabel } from "raft-ui";
import type { FeedbackBuildScope } from "../lib/api";

export function feedbackBuildScope(value: string | null): FeedbackBuildScope {
return value === "external" || value === "all" ? value : "standard";
}

/** The reported bundle is a hint, not verified client identity. */
export function FeedbackBuildScopeSelector({
value,
onChange,
}: {
value: FeedbackBuildScope;
onChange: (value: FeedbackBuildScope) => void;
}) {
return (
<SegmentedControl value={value} onValueChange={(next) => onChange(feedbackBuildScope(next))}
aria-label="Report origin" className="w-fit">
<SegmentedControlItem value="standard"><SegmentedControlLabel>App + unknown</SegmentedControlLabel></SegmentedControlItem>
<SegmentedControlItem value="external"><SegmentedControlLabel>External</SegmentedControlLabel></SegmentedControlItem>
<SegmentedControlItem value="all"><SegmentedControlLabel>All reports</SegmentedControlLabel></SegmentedControlItem>
</SegmentedControl>
);
}
8 changes: 4 additions & 4 deletions admin/src/components/FeedbackTrends.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Tooltip, TooltipTrigger, TooltipContent, Progress, ProgressTrack, ProgressIndicator } from "raft-ui";
import { getFeedbackStats } from "../lib/api";
import { getFeedbackStats, type FeedbackBuildScope } from "../lib/api";

const KINDS = ["feedback", "bug", "crash", "error"] as const;
const KIND_COLOR: Record<(typeof KINDS)[number], string> = {
Expand All @@ -27,10 +27,10 @@ const SURFACE = "var(--layer-canvas)"; // chart surface — 2px segment spacers
const GRIDLINE = "var(--line-hairline)";
const HOVER_BAND = "var(--fill-muted)";

export function FeedbackTrends({ appId }: { appId: string }) {
export function FeedbackTrends({ appId, buildScope }: { appId: string; buildScope?: FeedbackBuildScope }) {
const stats = useQuery({
queryKey: ["feedback-stats", appId],
queryFn: () => getFeedbackStats(appId),
queryKey: ["feedback-stats", appId, buildScope],
queryFn: () => getFeedbackStats(appId, buildScope),
});
const [showTable, setShowTable] = useState(false);

Expand Down
24 changes: 21 additions & 3 deletions admin/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1829,6 +1829,17 @@ export const rebindReleaseShare = (

// ---- Feedback tickets (task #66) ----

export type FeedbackBuildOrigin = "configured" | "external" | "unknown" | null;
export type FeedbackBuildScope = "standard" | "external" | "all";

export const getFeedbackBuildFilter = (appId: string) =>
request<{ default_enabled: number }>(`/api/apps/${appId}/feature-flags/feedback_build_origin_filter`, { admin: true });

export const setFeedbackBuildFilter = (appId: string, enabled: boolean) =>
request<{ ok: boolean }>(`/api/apps/${appId}/feature-flags/feedback_build_origin_filter`, {
method: "PUT", admin: true, body: JSON.stringify({ default_enabled: enabled }),
});

export interface FeedbackTicket {
id: string;
kind: "feedback" | "bug" | "crash" | "error";
Expand All @@ -1854,6 +1865,8 @@ export interface FeedbackTicket {
updated_at: number;
attachment_count: number;
comment_count: number;
/** Client-reported bundle classification, or null when separation is disabled. */
build_origin?: FeedbackBuildOrigin;
/** Crash flavour for crash/error tickets (derived server-side); null otherwise. */
crash_type?: CrashType | null;
}
Expand Down Expand Up @@ -1894,6 +1907,7 @@ export const listFeedback = (
versionCode?: number | undefined;
signature?: string | undefined;
crashType?: CrashType | undefined;
buildScope?: FeedbackBuildScope | undefined;
},
) => {
const params = new URLSearchParams();
Expand All @@ -1903,6 +1917,7 @@ export const listFeedback = (
if (filters?.versionCode != null) params.set("version_code", String(filters.versionCode));
if (filters?.signature) params.set("signature", filters.signature);
if (filters?.crashType) params.set("crash_type", filters.crashType);
if (filters?.buildScope) params.set("build_scope", filters.buildScope);
const qs = params.toString();
return request<{ tickets: FeedbackTicket[] }>(
`/api/apps/${appId}/feedback${qs ? `?${qs}` : ""}`,
Expand Down Expand Up @@ -2238,13 +2253,16 @@ export interface FeedbackStats {
crashes_by_version: Array<{ version_name: string; version_code: number | null; n: number }>;
}

export const getFeedbackStats = (appId: string) =>
request<FeedbackStats>(`/api/apps/${appId}/feedback/stats`, { admin: true });
export const getFeedbackStats = (appId: string, buildScope?: FeedbackBuildScope) => {
const query = buildScope ? `?build_scope=${buildScope}` : "";
return request<FeedbackStats>(`/api/apps/${appId}/feedback/stats${query}`, { admin: true });
};

export const listCrashGroups = (appId: string, kind?: string, crashType?: CrashType) => {
export const listCrashGroups = (appId: string, kind?: string, crashType?: CrashType, buildScope?: FeedbackBuildScope) => {
const params = new URLSearchParams();
if (kind) params.set("kind", kind);
if (crashType) params.set("crash_type", crashType);
if (buildScope) params.set("build_scope", buildScope);
const qs = params.toString();
return request<{ groups: CrashGroup[] }>(
`/api/apps/${appId}/feedback/crash-groups${qs ? `?${qs}` : ""}`,
Expand Down
36 changes: 36 additions & 0 deletions admin/src/pages/AppDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
updateAppDeltaUpdates,
updateAppReleaseApproval,
getAppClientKey,
getFeedbackBuildFilter,
setFeedbackBuildFilter,
rotateAppClientKey,
purgeApp,
listOrgs,
Expand Down Expand Up @@ -1042,6 +1044,38 @@ function AppIconUploader({ appId, slug }: { appId: string; slug: string }) {
);
}

function FeedbackBuildFilterToggle({ appId }: { appId: string }) {
const toast = useToast();
const qc = useQueryClient();
const setting = useQuery({ queryKey: ["feedback-build-filter", appId], queryFn: () => getFeedbackBuildFilter(appId) });
const enabled = setting.data?.default_enabled === 1;
const update = useMutation({
mutationFn: (next: boolean) => setFeedbackBuildFilter(appId, next),
onSuccess: (_, next) => {
toast.show({ kind: "success", title: next ? "External report separation enabled" : "External report separation disabled" });
qc.invalidateQueries({ queryKey: ["feedback-build-filter", appId] });
qc.invalidateQueries({ queryKey: ["feedback", appId] });
qc.invalidateQueries({ queryKey: ["feedback-stats", appId] });
qc.invalidateQueries({ queryKey: ["crash-groups", appId] });
qc.invalidateQueries({ queryKey: ["error-groups", appId] });
},
onError: (e) => toast.show({ kind: "error", title: "Update failed", description: (e as Error).message }),
});
return (
<div className="flex items-center gap-3">
<div className="flex-1">
<div className="text-sm font-medium">Separate external iOS reports</div>
<div className="text-xs text-foreground-muted">
Compare reported bundle IDs with this app's configured channels. Mismatches appear in the external view and do not create normal notification cards; unknown IDs stay in the main view. Bundle IDs are supplied by clients and do not verify identity.
</div>
{setting.error && <div className="text-xs text-danger">Could not load this setting.</div>}
</div>
<Switch aria-label="Separate external iOS reports" checked={enabled}
disabled={!setting.data || update.isPending} onCheckedChange={(next) => update.mutate(next)} />
</div>
);
}

export function AppSettings({ appId }: { appId: string }) {
const qc = useQueryClient();
const toast = useToast();
Expand Down Expand Up @@ -1127,6 +1161,8 @@ export function AppSettings({ appId }: { appId: string }) {
<ReleaseApprovalToggle appId={appId} app={app} />
)}

{app.platform === "ios" && <FeedbackBuildFilterToggle appId={appId} />}

{/* Client key (feedback/crash reporting auth) */}
<ClientKeyPanel appId={appId} />

Expand Down
25 changes: 19 additions & 6 deletions admin/src/pages/Crashes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
*/
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getFeedbackStats, listCrashGroups, type CrashType } from "../lib/api";
import { getFeedbackStats, getFeedbackBuildFilter, listCrashGroups, type CrashType } from "../lib/api";
import { Badge, EmptyState, EmptyStateContent, EmptyStateIcon, EmptyStateTitle, SegmentedControl, SegmentedControlItem, SegmentedControlLabel, type BadgeProps } from "raft-ui";
import { Bug } from "lucide-react";
import { CrashByVersion } from "../components/FeedbackTrends";
import { FeedbackBuildScopeSelector, feedbackBuildScope } from "../components/FeedbackBuildScope";

export const CRASH_TYPE_LABELS: Record<CrashType, string> = {
exception: "Exception",
Expand Down Expand Up @@ -37,19 +38,24 @@ export function AppCrashes({ appId }: { appId: string }) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const crashType = parseCrashType(searchParams.get("crash_type"));
const buildScope = feedbackBuildScope(searchParams.get("build_scope"));
const originPolicy = useQuery({
queryKey: ["feedback-build-filter", appId],
queryFn: () => getFeedbackBuildFilter(appId),
});
const setCrashType = (value: CrashType | "") => {
const next = new URLSearchParams(searchParams);
if (value) next.set("crash_type", value);
else next.delete("crash_type");
setSearchParams(next, { replace: true });
};
const groups = useQuery({
queryKey: ["crash-groups", appId, crashType],
queryFn: () => listCrashGroups(appId, undefined, crashType || undefined),
queryKey: ["crash-groups", appId, crashType, buildScope],
queryFn: () => listCrashGroups(appId, undefined, crashType || undefined, buildScope),
});
const stats = useQuery({
queryKey: ["feedback-stats", appId],
queryFn: () => getFeedbackStats(appId),
queryKey: ["feedback-stats", appId, buildScope],
queryFn: () => getFeedbackStats(appId, buildScope),
});
const rows = groups.data?.groups ?? [];

Expand Down Expand Up @@ -78,6 +84,13 @@ export function AppCrashes({ appId }: { appId: string }) {
</SegmentedControl>
</div>

{originPolicy.data?.default_enabled === 1 && (
<FeedbackBuildScopeSelector value={buildScope} onChange={(value) => {
const next = new URLSearchParams(searchParams);
if (value === "standard") next.delete("build_scope"); else next.set("build_scope", value);
setSearchParams(next, { replace: true });
}} />
)}
{(stats.data?.crashes_by_version.length ?? 0) > 0 && (
<div className="max-w-md">
<CrashByVersion rows={stats.data!.crashes_by_version} />
Expand Down Expand Up @@ -124,7 +137,7 @@ export function AppCrashes({ appId }: { appId: string }) {
className="border-b border-line-hairline last:border-0 cursor-pointer hover:bg-fill-muted"
onClick={() =>
navigate(
`/apps/${appId}/feedback?kind=crash&signature=${encodeURIComponent(g.signature)}`,
`/apps/${appId}/feedback?kind=crash&signature=${encodeURIComponent(g.signature)}&build_scope=${buildScope}`,
)
}
>
Expand Down
21 changes: 16 additions & 5 deletions admin/src/pages/Errors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@
* (kind=error) grouped by signature. Clicking a group jumps to the Feedback
* list pre-filtered to error tickets.
*/
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { listCrashGroups } from "../lib/api";
import { getFeedbackBuildFilter, listCrashGroups } from "../lib/api";
import { EmptyState, EmptyStateContent, EmptyStateIcon, EmptyStateTitle } from "raft-ui";
import { AlertTriangle } from "lucide-react";
import { FeedbackBuildScopeSelector, feedbackBuildScope } from "../components/FeedbackBuildScope";

export function AppErrors({ appId }: { appId: string }) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const buildScope = feedbackBuildScope(searchParams.get("build_scope"));
const originPolicy = useQuery({ queryKey: ["feedback-build-filter", appId], queryFn: () => getFeedbackBuildFilter(appId) });
const groups = useQuery({
queryKey: ["error-groups", appId],
queryFn: () => listCrashGroups(appId, "error"),
queryKey: ["error-groups", appId, buildScope],
queryFn: () => listCrashGroups(appId, "error", undefined, buildScope),
});
const rows = groups.data?.groups ?? [];

Expand All @@ -30,6 +34,13 @@ export function AppErrors({ appId }: { appId: string }) {
</div>
</div>

{originPolicy.data?.default_enabled === 1 && (
<FeedbackBuildScopeSelector value={buildScope} onChange={(value) => {
const next = new URLSearchParams(searchParams);
if (value === "standard") next.delete("build_scope"); else next.set("build_scope", value);
setSearchParams(next, { replace: true });
}} />
)}
<div className="overflow-x-auto">
{groups.isLoading && <p className="text-sm text-foreground-muted">Loading…</p>}
{groups.error && (
Expand Down Expand Up @@ -67,7 +78,7 @@ export function AppErrors({ appId }: { appId: string }) {
className="border-b border-line-hairline last:border-0 cursor-pointer hover:bg-fill-muted"
onClick={() =>
navigate(
`/apps/${appId}/feedback?kind=error&signature=${encodeURIComponent(g.signature)}`,
`/apps/${appId}/feedback?kind=error&signature=${encodeURIComponent(g.signature)}&build_scope=${buildScope}`,
)
}
>
Expand Down
21 changes: 19 additions & 2 deletions admin/src/pages/Feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
downloadFeedbackAttachment,
getAuthMe,
getFeedback,
getFeedbackBuildFilter,
getFeedbackAttachmentBlob,
listFeedback,
getDeviceDetail,
Expand All @@ -22,6 +23,7 @@ import {
import { feedbackMessage } from "../lib/feedbackMessages";
import { useToast } from "../components/Toast";
import { FeedbackTrends } from "../components/FeedbackTrends";
import { FeedbackBuildScopeSelector, feedbackBuildScope } from "../components/FeedbackBuildScope";
import { MarkdownText } from "../components/MarkdownText";
import {
AlertDialog,
Expand Down Expand Up @@ -101,9 +103,14 @@ export function AppFeedback({ appId }: { appId: string }) {
const deviceFilter = searchParams.get("device_id") ?? "";
const versionFilter = searchParams.get("version_code") ?? "";
const signatureFilter = searchParams.get("signature") ?? "";
const buildScope = feedbackBuildScope(searchParams.get("build_scope"));
const originPolicy = useQuery({
queryKey: ["feedback-build-filter", appId],
queryFn: () => getFeedbackBuildFilter(appId),
});

const tickets = useQuery({
queryKey: ["feedback", appId, statusFilter, kindFilter, deviceFilter, versionFilter, signatureFilter],
queryKey: ["feedback", appId, statusFilter, kindFilter, deviceFilter, versionFilter, signatureFilter, buildScope],
queryFn: () =>
listFeedback(appId, {
status: statusFilter || undefined,
Expand All @@ -113,6 +120,7 @@ export function AppFeedback({ appId }: { appId: string }) {
deviceId: deviceFilter || undefined,
versionCode: versionFilter ? Number(versionFilter) : undefined,
signature: signatureFilter || undefined,
buildScope,
}),
});

Expand Down Expand Up @@ -163,7 +171,7 @@ export function AppFeedback({ appId }: { appId: string }) {
</div>
</div>
)}
{!deviceFilter && !versionFilter && !signatureFilter && <FeedbackTrends appId={appId} />}
{!deviceFilter && !versionFilter && !signatureFilter && <FeedbackTrends appId={appId} buildScope={buildScope} />}
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-lg font-semibold">Feedback</h2>
Expand Down Expand Up @@ -212,6 +220,13 @@ export function AppFeedback({ appId }: { appId: string }) {
</div>


{originPolicy.data?.default_enabled === 1 && (
<FeedbackBuildScopeSelector value={buildScope} onChange={(value) => {
const next = new URLSearchParams(searchParams);
if (value === "standard") next.delete("build_scope"); else next.set("build_scope", value);
setSearchParams(next, { replace: true });
}} />
)}
<div className="overflow-x-auto">
{tickets.isLoading && (
<div className="space-y-2">
Expand Down Expand Up @@ -260,6 +275,8 @@ export function AppFeedback({ appId }: { appId: string }) {
ANR
</Badge>
)}
{t.build_origin === "external" && <Badge variant="warning" className="mr-2">External</Badge>}
{t.build_origin === "unknown" && <Badge variant="muted" className="mr-2">Origin unknown</Badge>}
<span className="align-middle">{t.message.slice(0, 80)}{t.message.length > 80 ? "…" : ""}</span>
</td>
<td className="py-2 pr-3">
Expand Down
12 changes: 12 additions & 0 deletions docs/feedback-material-delta-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,18 @@ order the page and create `next_cursor`. `has_more` comes from the extra row;
that row is not returned or acknowledged. An empty page preserves the input
checkpoint rather than advancing to the allocator's current high-water.

For iOS apps that opt in to external report separation, each snapshot also has
`build_origin`: `configured` if its client-reported bundle ID matches any
currently configured channel, `external` if it does not, and `unknown` if the
ID or comparison set cannot be used. With the feature disabled, it is `null`.
This value is an untrusted triage hint, not an identity or authorization check.
The feed still returns every snapshot; consumers may skip `external` while
advancing their existing cursor only after processing the entire page. The
classification is evaluated when the feed is read. Changing channel bundles
does not create a material event for old tickets, so consumers who need to
revisit older classifications must scan the list or perform an idempotent
rescan from an earlier cursor.

The only accepted cursor payload is exactly
`["material-v1", app_id, material_sequence]`, encoded behind the existing
cursor helper. The app id must equal the path parameter and the sequence must be
Expand Down
Loading
Loading